Compare commits
288
Commits
4834905b24
...
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 | ||
|
|
6de2f7656a | ||
|
|
5f7489d70e | ||
|
|
a60e3abc12 | ||
|
|
14955ef6c0 | ||
|
|
9ff2de3a1d | ||
|
|
f3c1121887 | ||
|
|
a861881ec4 | ||
|
|
b3ded77c23 | ||
|
|
464e91621e | ||
|
|
ec89d828d1 | ||
|
|
1142bf5ed1 | ||
|
|
53339b369a | ||
|
|
e5e61e131d | ||
|
|
33c320219a | ||
|
|
827f0e61a4 | ||
|
|
30c53451dc | ||
|
|
514901c12d | ||
|
|
59012f711b | ||
|
|
863ac93b25 | ||
|
|
d8e44c6692 | ||
|
|
62f552f87f | ||
|
|
77fbedc350 | ||
|
|
0a43cedea9 | ||
|
|
b06688f1db | ||
|
|
6505512c0a | ||
|
|
bbd02fc279 | ||
|
|
53c7494389 | ||
|
|
611bc5f58a | ||
|
|
659ab83f00 | ||
|
|
ed0824f335 | ||
|
|
f3d8d28cf7 | ||
|
|
f8ac95ceed | ||
|
|
46855af2dd | ||
|
|
0054a3a97a | ||
|
|
126a706422 | ||
|
|
579eccdfd1 | ||
|
|
0243fb78d4 | ||
|
|
d6b0c7cb20 | ||
|
|
37289391f8 | ||
|
|
bbc47dbd31 | ||
|
|
84f343ce5f | ||
|
|
aace581fb0 | ||
|
|
27116fa434 | ||
|
|
85818105ce | ||
|
|
74a46bd2e3 | ||
|
|
d0b8c5f2f7 | ||
|
|
4fce12249e | ||
|
|
9f04fc4304 | ||
|
|
0583b1bc31 | ||
|
|
b604fefbd4 | ||
|
|
620a4e77fa | ||
|
|
9980fb2b28 | ||
|
|
a9cc3c5737 | ||
|
|
a025624798 | ||
|
|
1d150cce85 | ||
|
|
b23948bed0 | ||
|
|
c905a47d6f | ||
|
|
afee5f612d | ||
|
|
8b5d15bdb6 | ||
|
|
5520d6015d | ||
|
|
4c938c7330 | ||
|
|
d9b7c22ecb | ||
|
|
d5d2feee36 | ||
|
|
5dc6408bb9 | ||
|
|
93cf3e359a | ||
|
|
e589ab303d | ||
|
|
40dac87dda | ||
|
|
766843fdbd | ||
|
|
4f860aa499 | ||
|
|
a4ac11a1c1 | ||
|
|
7d1c56e03b | ||
|
|
c09c40388b | ||
|
|
324be57c7a | ||
|
|
df8df67742 | ||
|
|
f2075c1f49 | ||
|
|
df20a21fb5 | ||
|
|
669d05b781 | ||
|
|
ff699f5079 | ||
|
|
3a07e94180 | ||
|
|
ce50bd2c24 | ||
|
|
798c6ee907 | ||
|
|
16699c4958 | ||
|
|
d546f31755 | ||
|
|
b8399b5949 | ||
|
|
b18d864145 | ||
|
|
1d548ddcda | ||
|
|
ec8d54f3a5 | ||
|
|
2bee0b52b0 | ||
|
|
06028cc352 | ||
|
|
866a3c37cd | ||
|
|
4460ca9277 | ||
|
|
3c1d3fd44c | ||
|
|
5556573986 | ||
|
|
f3ee2e0d7b | ||
|
|
4355ebddea | ||
|
|
5a9f52a069 | ||
|
|
97992bdb6b | ||
|
|
eb22521269 | ||
|
|
9f77fb469c | ||
|
|
7b981dd88a | ||
|
|
3611455357 | ||
|
|
31e8e31679 | ||
|
|
67b2e58fdf | ||
|
|
df9fc85f76 | ||
|
|
e60ae7aecc | ||
|
|
b55d220e28 | ||
|
|
a763592ace | ||
|
|
4eaee04255 | ||
|
|
f15f0bf9bc | ||
|
|
d8228fb315 | ||
|
|
aeb37458e8 | ||
|
|
44b58db58a | ||
|
|
a2350a6d5f | ||
|
|
0764bc269c | ||
|
|
6cdf111419 | ||
|
|
ef257c3167 | ||
|
|
405388e325 | ||
|
|
39a63f8d30 | ||
|
|
6e00697110 | ||
|
|
5d4888ab71 | ||
|
|
108feefac4 | ||
|
|
681ae96a9c | ||
|
|
95e657e704 | ||
|
|
98b2274e68 | ||
|
|
cb5fc0c605 | ||
|
|
b0b73d063b | ||
|
|
54e7b35e60 | ||
|
|
9e6a6b395c | ||
|
|
83ea5ddff7 | ||
|
|
e1223fdee5 | ||
|
|
c3cfef75bb | ||
|
|
c73dd532ff | ||
|
|
1f6c16c590 | ||
|
|
f2fbdff9a7 | ||
|
|
79fcd803a0 | ||
|
|
e85209eebb | ||
|
|
bb4bb583d5 | ||
|
|
4847d74d87 | ||
|
|
a776e9f608 | ||
|
|
82d3ca3953 | ||
|
|
005720575a | ||
|
|
29299b190e | ||
|
|
d0bb43c87c | ||
|
|
5e6dc76ae8 | ||
|
|
0d50bc82ca | ||
|
|
ac3f63f8c8 | ||
|
|
be1c372d99 | ||
|
|
c0e78371cb | ||
|
|
7b97440973 | ||
|
|
276b01113b | ||
|
|
27d5680d21 | ||
|
|
86a2217858 | ||
|
|
a0085802dd | ||
|
|
ae49853029 | ||
|
|
691b073e32 | ||
|
|
6c2cc57cdb | ||
|
|
219c50b7c2 | ||
|
|
85a922c01b | ||
|
|
b7ac6b8853 | ||
|
|
3c0ed16ee7 | ||
|
|
eacc65acc9 | ||
|
|
805d8b530a | ||
|
|
e4c6c7c72a | ||
|
|
cfdc094c30 | ||
|
|
b8f0833137 | ||
|
|
b5c0b13210 | ||
|
|
1794e53d1c | ||
|
|
2d382b11cd | ||
|
|
66c2291312 | ||
|
|
e4915a6904 | ||
|
|
28c314262e | ||
|
|
e5fa1b871f | ||
|
|
a84d473e2f | ||
|
|
9f92a8ade5 | ||
|
|
d2169c6174 | ||
|
|
92ee7fedbd | ||
|
|
647cf47cf2 | ||
|
|
3ff4f45166 | ||
|
|
c67aa2da48 | ||
|
|
e8c6389fde | ||
|
|
df6378395e | ||
|
|
8938fffd08 | ||
|
|
7a6f6b0863 | ||
|
|
ea16f48be2 | ||
|
|
351891714a | ||
|
|
5d8ab15c0e | ||
|
|
1f9d1dde01 | ||
|
|
48e41886ba | ||
|
|
020687a82b | ||
|
|
440e441b31 | ||
|
|
d3fc0c8005 | ||
|
|
b579732db6 | ||
|
|
46ac15a538 | ||
|
|
23b46f9310 | ||
|
|
8b907036db | ||
|
|
af11a94614 | ||
|
|
76d49b73d1 | ||
|
|
f10984622c | ||
|
|
557ef3f524 | ||
|
|
9e039863fa | ||
|
|
9854cc477d | ||
|
|
3b4a790e30 | ||
|
|
8633ec9f06 | ||
|
|
ffa7ed2661 | ||
|
|
60b0eacafd | ||
|
|
27b6910009 | ||
|
|
a0d6e3479f | ||
|
|
a7dbee847e | ||
|
|
642bdb13f0 | ||
|
|
c94ea09b59 | ||
|
|
e59ef08cf9 | ||
|
|
ca5596a732 | ||
|
|
2369dd2115 | ||
|
|
29733914a4 | ||
|
|
ab76798bff | ||
|
|
30180ab1e5 | ||
|
|
93941cd1ea | ||
|
|
b5e6f78910 | ||
|
|
f24877d53b | ||
|
|
3ed2377b3c | ||
|
|
a6b4bae2a0 | ||
|
|
a80dac7a7e | ||
|
|
f91a8558ea | ||
|
|
557cdb213d | ||
|
|
095d6e781e | ||
|
|
217cfe3d5f |
No files matched your search
+8
-2
@@ -1,12 +1,18 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
build/
|
||||
.kotlin/
|
||||
# 本机开发凭据(主人授权落地的测试用 key 等)——只在本机,绝不进仓库
|
||||
dev-secrets.local.md
|
||||
local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
|
||||
# 本地设计/协作文档,不进仓库
|
||||
# 主人从手机丢进仓库根当素材用的图(例如换图标用的原图)——只用不存,绝不进仓库
|
||||
/IMG_*.png
|
||||
# 开发文档 —— **主人 2026-10-07 明确指令:整个文件夹禁止进入远程仓库**。
|
||||
# 它只在本机磁盘上(前后端协同留言板、进度交接、设计拆解都在里面),靠磁盘本身交接。
|
||||
# 凭据同样绝不写进去(那类东西在 dev-secrets.local.md)。
|
||||
devdocs/
|
||||
@@ -1,6 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!--
|
||||
「后台保持连接」用到的三个权限(设置页那个开关,默认关):
|
||||
|
||||
- FOREGROUND_SERVICE:起前台服务本身。
|
||||
- FOREGROUND_SERVICE_DATA_SYNC:前台服务的类型权限(API 34+ 与 android:foregroundServiceType
|
||||
配合)。选 dataSync 是因为这条服务保的是一条**长连接**——保持与后端的实时同步,
|
||||
既不是 mediaPlayback 也不是 location,声明成别的类型才是说谎。
|
||||
- POST_NOTIFICATIONS:前台服务必须有一条通知,而 minSdk 33 ⇒ 它恒是运行时权限。
|
||||
用户拒绝时开关**打不开**(判据见 core.keepalive.shouldKeepAliveRun),本端不偷偷跑。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<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" />
|
||||
|
||||
<!--
|
||||
联网权限**显式**声明,不靠任何传递依赖。
|
||||
|
||||
事实(W52K 跑 `:app:processReleaseMainManifest` 核过):本端一直能联网,是靠
|
||||
`com.squareup.okhttp3:okhttp-android:5.5.0` 的清单把 `INTERNET` 合并进来的;而 debug 那边
|
||||
AGP 也注入同一份 ⇒ **本地怎么试都看不出来**。一旦换掉/排除那个依赖,release 会**静默失去**
|
||||
网络权限(不报错、只是所有请求都失败)。显式写在这里,从此与第三方清单无关。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".app.CyreneApplication"
|
||||
android:allowBackup="false"
|
||||
@@ -48,6 +82,38 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
附件预览的「用其它应用打开 / 保存」出口:`cacheDir/documents/` 下的文件要交给
|
||||
系统里别的 App 读,就必须是 content:// 而不是 file://——API 24 起把 file:// 交出去
|
||||
会直接抛 FileUriExposedException(且 StrictMode 下同样致命)。
|
||||
路径范围见 res/xml/file_paths.xml:只暴露 cacheDir/documents/ 这一层,
|
||||
exported="false" + 按次授权(grantUriPermissions)保证这份缓存不对外常开。
|
||||
-->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!--
|
||||
「后台保持连接」的前台服务(设置页开关控制,默认关)。
|
||||
|
||||
exported="false":它没有任何对外接口(onBind 返回 null),只由本进程 start/stop。
|
||||
|
||||
foregroundServiceType="dataSync":这条服务的职责是让进程活着,从而让那条**进程级共享**
|
||||
的实时连接(AppContainer.chatSocket)不被 Android 12+ 的 cached 进程冻结掐断——
|
||||
语义上就是「后台数据同步」。它不自己去建立 / 关闭任何连接,连接的主人是
|
||||
SessionsViewModel(见 KeepAliveService 的类注释)。
|
||||
-->
|
||||
<service
|
||||
android:name=".core.keepalive.KeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -3,19 +3,31 @@ package top.yeij.cyrene.app
|
||||
import android.content.Context
|
||||
import top.yeij.cyrene.core.auth.AuthManager
|
||||
import top.yeij.cyrene.core.auth.SessionStore
|
||||
import top.yeij.cyrene.core.notify.AppPresence
|
||||
import top.yeij.cyrene.core.notify.ProactiveNotifier
|
||||
import top.yeij.cyrene.core.notify.postProactiveNotification
|
||||
import top.yeij.cyrene.core.prefs.AppSettings
|
||||
import top.yeij.cyrene.core.prefs.EnumPreferences
|
||||
import top.yeij.cyrene.core.prefs.SessionPreferences
|
||||
import top.yeij.cyrene.core.session.ChatSessionSlots
|
||||
import top.yeij.cyrene.core.session.GoneSessions
|
||||
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
|
||||
import top.yeij.cyrene.data.api.UploadApi
|
||||
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
|
||||
import top.yeij.cyrene.data.repository.SessionRepository
|
||||
import top.yeij.cyrene.data.repository.UploadRepository
|
||||
import top.yeij.cyrene.data.ws.ChatSocket
|
||||
@@ -32,6 +44,60 @@ class AppContainer(context: Context) {
|
||||
val appSettings: AppSettings = AppSettings(context.applicationContext)
|
||||
val authManager: AuthManager = AuthManager(sessionStore)
|
||||
|
||||
/**
|
||||
* 本端已知「服务端没有这个会话」的那几个 id(**进程级**,与 [chatSocket] 同一层)。
|
||||
*
|
||||
* 跨两个 VM 的唯一共享面:写它的是 `SessionsViewModel`(本端唯一的删除入口),读它的是
|
||||
* **每一个还活着的** `ChatViewModel`(它们在 [chatSessionSlots] 没回收之前都活着、都订阅着
|
||||
* 那条共享连接)。它拦下的是那一轮注定 `404 session_not_found` 的补拉 —— 理由与
|
||||
* 「为什么不持久化」见 [GoneSessions] 的类注释。
|
||||
*/
|
||||
val goneSessions: GoneSessions = GoneSessions()
|
||||
|
||||
/**
|
||||
* 「App 在不在前台」「聊天页显示着哪个会话」(**进程级**,与 [chatSocket] / [goneSessions]
|
||||
* 同一层):写它的是 `MainActivity`(`onStart` / `onStop`)与 `ChatScreen`(组合进出),
|
||||
* 读它的是 [proactiveNotifier]。为什么不引 `ProcessLifecycleOwner` 见 [AppPresence] 的类注释。
|
||||
*/
|
||||
val appPresence: AppPresence = AppPresence()
|
||||
|
||||
/** 「点通知进来要看主会话」那笔待落地的意图(写:`MainActivity`,消费:`ChatHomeScreen`)。 */
|
||||
val proactiveTapIntake: ProactiveTapIntake = ProactiveTapIntake()
|
||||
|
||||
/**
|
||||
* 「她主动说的话」的通知通路(W53N):判据、去重与落地都在里面,Android 那一步从这里注入。
|
||||
*
|
||||
* 它**不订阅任何东西**(帧由 [proactiveFrameIntake] 喂进来),因此与连接的所有权完全无关
|
||||
* ——本端那条共享连接的生死仍然只在登录态那一处表达。
|
||||
*/
|
||||
val proactiveNotifier: ProactiveNotifier = ProactiveNotifier(
|
||||
presence = appPresence,
|
||||
post = { text, notificationId ->
|
||||
// 「免打扰」**每次现读**(`AppSettings.current` 是那份热副本):开关一改,下一条通知就
|
||||
// 换一条渠道,不必重启 App。为什么不能在这里挂起读一次、以及那个毫秒级窗口是怎么回事,
|
||||
// 都写在 `AppSettings.current` 的 KDoc 里。
|
||||
postProactiveNotification(
|
||||
context = appContext,
|
||||
text = text,
|
||||
notificationId = notificationId,
|
||||
quiet = appSettings.current.value.proactiveQuiet,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 会话槽位账本(**进程级**):本端为一个账号留着的聊天状态,一格一份 `ViewModelStore`。
|
||||
*
|
||||
* 它把 S4 那笔技术债收在一处:`ChatViewModel` 是按会话建的,此前访问过多少个会话就有多少份
|
||||
* 常驻内存(消息列表含内联 base64 图片)与多少份对共享连接的订阅;现在最多留
|
||||
* [top.yeij.cyrene.core.session.CHAT_SESSION_SLOT_KEEP] 格,超出的按「最后一次成为当前会话」
|
||||
* 从旧到新交出去(判据是纯函数 `core.session.chatSessionEvictions`)。
|
||||
*
|
||||
* 与 [goneSessions] 同一层、同一批读者(写它的是 `ChatScreen` 与 `SessionsViewModel`)。
|
||||
* 主会话 id 在这里注入,是为了让 `core.session` 那两层不认识 `ui.chat` 的那个常量。
|
||||
*/
|
||||
val chatSessionSlots: ChatSessionSlots = ChatSessionSlots(SessionSummaryDto.MAIN_SESSION_ID)
|
||||
|
||||
private var cachedBaseUrl: String? = null
|
||||
private var cachedSessionsApi: SessionsApi? = null
|
||||
|
||||
@@ -103,6 +169,63 @@ class AppContainer(context: Context) {
|
||||
|
||||
val uploadRepository: UploadRepository get() = UploadRepository(uploadApi)
|
||||
|
||||
private var cachedDocumentsApi: DocumentsApi? = null
|
||||
private var cachedDocumentsBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 文档存储端点(`POST /api/v1/documents` 上传、`GET /api/v1/documents/{name}` 取字节);
|
||||
* 与 [chatApi] **必须同源**(同一 baseUrl、同一 token/401 处理)——上传拿到的存储名就是要交给
|
||||
* 同一个后端的 `POST /api/v1/chat` 的 `documents[].name`,两条链路不在同一台机器上就必然 404。
|
||||
*
|
||||
* Bearer 与 401 续期不用在这里做额外事:[ApiClientFactory.create] 按 **OkHttpClient**
|
||||
* 挂 `AuthInterceptor`,文档读侧那点「与图片读侧刻意相反」的鉴权要求因此天然满足。
|
||||
*/
|
||||
val documentsApi: DocumentsApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedDocumentsApi
|
||||
if (cached != null && cachedDocumentsBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = DocumentsApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedDocumentsBaseUrl = baseUrl
|
||||
cachedDocumentsApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -147,10 +270,79 @@ class AppContainer(context: Context) {
|
||||
|
||||
val metaRepository: MetaRepository get() = MetaRepository(metaApi)
|
||||
|
||||
fun createChatSocket(): ChatSocket = ChatSocket(
|
||||
baseUrlProvider = { authManager.currentBaseUrl },
|
||||
tokenProvider = authManager::currentToken,
|
||||
onAuthRecover = authManager::recoverSocketAuth,
|
||||
onAuthFailed = authManager::onSessionExpired,
|
||||
private var cachedPersonaApi: PersonaApi? = null
|
||||
private var cachedPersonaBaseUrl: String? = null
|
||||
|
||||
/** 用户侧角色目录(`GET /api/v1/personas`);与 [metaApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val personaApi: PersonaApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedPersonaApi
|
||||
if (cached != null && cachedPersonaBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = PersonaApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedPersonaBaseUrl = baseUrl
|
||||
cachedPersonaApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val personaRepository: PersonaRepository get() = PersonaRepository(personaApi)
|
||||
|
||||
/**
|
||||
* 进程内**唯一**的实时通道连接(`GET /ws/chat`):所有需要收下行帧的 ViewModel 共用这一条。
|
||||
*
|
||||
* ## 为什么可以共享(不是「省点资源」,是**逐帧等价**)
|
||||
* 服务端是**广播**:下行帧发给**每一条**已认证连接、**不按会话订阅**(见 [ChatSocket] 类注释
|
||||
* 「会话归属」)。于是改前「N 条连接,每条都收到全部帧 ⇒ 每个 VM 都收到全部帧 ⇒ 各 VM 靠
|
||||
* [top.yeij.cyrene.data.ws.WsEvent.isForSession] 收敛到自己的会话」与改后「一条连接收到全部帧
|
||||
* ⇒ **每个 VM 仍然收到全部帧**」是**同一个语义**:帧集合逐帧相同,过滤与事件分支一个字都不用改。
|
||||
* 这次变的只是「连接有几条」,不是「帧怎么走」。
|
||||
*
|
||||
* ## 为什么必须唯一
|
||||
* 每个 `ChatSocket` 都自带一个 `OkHttpClient`(各自的连接池 / ping / 重连循环)。改前
|
||||
* `ChatViewModel`(**每个会话一个 VM**)、[top.yeij.cyrene.ui.sessions.SessionsViewModel]、
|
||||
* [top.yeij.cyrene.ui.admin.AdminViewModel] 各自建了一条(`ChatViewModel.start()`、
|
||||
* 两个 VM 的 init)⇒ 访问 N 个会话 = N+2 条连接、N+2 个 `OkHttpClient`。现在这里就是唯一的
|
||||
* 持有者(`by lazy`,只此一份),调用方只订阅 [ChatSocket.events] / [ChatSocket.state]。
|
||||
*
|
||||
* **每个会话那个 VM 常驻的那一半**(S4)由 [chatSessionSlots] 收:本端最多留
|
||||
* [top.yeij.cyrene.core.session.CHAT_SESSION_SLOT_KEEP] 格,多出来的整格交出去。这一条与
|
||||
* 本段说的是**两件事**:连接只有一条,而订阅它的 VM 有几格 —— 前者与本类同一层,后者在账本里。
|
||||
*
|
||||
* ## 开与关只有一个主人
|
||||
* [top.yeij.cyrene.ui.sessions.SessionsViewModel] 的登录态订阅:`LoggedIn → connect()`、
|
||||
* `LoggedOut → close()`(为什么是它,见那个类的 KDoc)。`connect()` 幂等,baseUrl / token 都是
|
||||
* 每次重连现读 ⇒ 登出重登、换账号**不需要**重建本实例。**任何 ViewModel 都不许关它**——
|
||||
* 细节与理由写在 [ChatSocket.close]。
|
||||
*/
|
||||
val chatSocket: ChatSocket by lazy {
|
||||
ChatSocket(
|
||||
baseUrlProvider = { authManager.currentBaseUrl },
|
||||
tokenProvider = authManager::currentToken,
|
||||
onAuthRecover = authManager::recoverSocketAuth,
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -5,8 +5,14 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.core.keepalive.KeepAliveAction
|
||||
import top.yeij.cyrene.core.keepalive.KeepAliveService
|
||||
import top.yeij.cyrene.core.keepalive.keepAliveAction
|
||||
import top.yeij.cyrene.core.keepalive.notificationsAllowed
|
||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
||||
import top.yeij.cyrene.ui.chat.ChatHomeScreen
|
||||
import top.yeij.cyrene.ui.common.LoadingScreen
|
||||
import top.yeij.cyrene.ui.login.LoginScreen
|
||||
@@ -14,12 +20,44 @@ import top.yeij.cyrene.ui.login.LoginScreen
|
||||
@Composable
|
||||
fun CyreneApp(container: AppContainer) {
|
||||
val state by container.authManager.state.collectAsState()
|
||||
val prefs = LocalAppPrefs.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
container.authManager.bootstrap()
|
||||
}
|
||||
|
||||
/*
|
||||
* 后台保持连接:**全 App 唯一一处**决定那条前台服务的起停。
|
||||
*
|
||||
* 这里是合适的位置,因为它是「开关(App 偏好)与登录态碰头」的最小汇合点:偏好由
|
||||
* CyreneRoot 读出来经 LocalAppPrefs 下发,登录态本来就订阅在本函数里。判据本身是纯函数
|
||||
* (core.keepalive.keepAliveAction),两种状态各自变化都会把这一次重跑一遍,于是
|
||||
* 「启动时开关开着且已登录 ⇒ 服务在跑」「登出 ⇒ 停」「开关关掉 ⇒ 停」都不用另外写路径。
|
||||
*
|
||||
* 权限每次**现读**(不缓存在 remember 里):会话自举完成那一刻读到的就是当下的真实授权,
|
||||
* 而授权变化只有两条路——本端刚申请完(设置页那次写入会连带把这里重跑一遍)、或用户在系统
|
||||
* 设置里改过(下次启动必然重跑)。
|
||||
*/
|
||||
// ⚠️ key 里必须有「回到前台」这一位(W53S 登记的那一格):通知权限是**现读**的,而用户在系统
|
||||
// 设置里授权后回到 App 时,只有这个状态会变 —— 少了它,UI 会说「已开」而服务其实没起。
|
||||
val appInForeground by container.appPresence.appInForegroundFlow.collectAsState()
|
||||
LaunchedEffect(prefs.keepAliveEnabled, state, appInForeground) {
|
||||
when (
|
||||
keepAliveAction(
|
||||
enabled = prefs.keepAliveEnabled,
|
||||
sessionKnown = state !is SessionState.Unknown,
|
||||
loggedIn = state is SessionState.LoggedIn,
|
||||
notificationsAllowed = context.notificationsAllowed(),
|
||||
)
|
||||
) {
|
||||
KeepAliveAction.Start -> KeepAliveService.start(context)
|
||||
KeepAliveAction.Stop -> KeepAliveService.stop(context)
|
||||
KeepAliveAction.Leave -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
when (state) {
|
||||
SessionState.Unknown -> LoadingScreen()
|
||||
SessionState.LoggedOut -> LoginScreen(container)
|
||||
|
||||
@@ -7,6 +7,9 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val container: AppContainer get() = (application as CyreneApplication).container
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -17,22 +20,47 @@ class MainActivity : ComponentActivity() {
|
||||
// intent 再送一遍,照单全收就会把同一条分享发第二次。重建带 savedInstanceState,
|
||||
// 冷启动不带——这就是判据。
|
||||
if (savedInstanceState == null) {
|
||||
intent?.let { ShareIntake.accept(this, it) }
|
||||
intent?.let {
|
||||
ShareIntake.accept(this, it)
|
||||
// 点她那条通知冷启动进来:记一笔「要看主会话」(见 ProactiveTapIntake)。
|
||||
// 与分享同一个判据:重建时系统会把同一个 intent 再送一遍,那时不能重复记账
|
||||
// (重复没有害处,但「冷启动才接」这条纪律只有一处,别在两处各写一半)。
|
||||
container.proactiveTapIntake.accept(it)
|
||||
}
|
||||
}
|
||||
val container = (application as CyreneApplication).container
|
||||
setContent {
|
||||
CyreneRoot(container)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本 App 只有**一个** Activity(`singleTop`),所以它的 start / stop 就是「App 有没有在被看」
|
||||
* ——这是「她主动说的话该不该弹通知」的两半之一(见 `core.notify.shouldNotifyProactive`)。
|
||||
*
|
||||
* 报的是**实例**而不是一个布尔量:旋转 / 系统深浅色切换会让新实例的 `onStart` 先于旧实例的
|
||||
* `onStop`,布尔量在那一刻必然被记成「后台」,于是用户正看着聊天页她却弹一条通知出来。
|
||||
* 集合由 [AppPresence] 自己持有,这里只报自己。
|
||||
*/
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
container.appPresence.onActivityStarted(this)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
container.appPresence.onActivityStopped(this)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
/**
|
||||
* 热启动(`launchMode="singleTop"`):App 已经开着时系统不再新建实例,只回调这里。
|
||||
* 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有。
|
||||
* 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有,而
|
||||
* 「点她的通知 → 回到已经开着的 Cyrene(可能停在设置页)」同理。
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// 让 getIntent() 与新到的 intent 一致(系统不会自己更新它),否则后续读到的还是旧的那个。
|
||||
setIntent(intent)
|
||||
ShareIntake.accept(this, intent)
|
||||
container.proactiveTapIntake.accept(intent)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.content.Intent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import top.yeij.cyrene.core.notify.EXTRA_PROACTIVE_OPEN_MAIN_SESSION
|
||||
|
||||
/**
|
||||
* 「点她那条通知进来 ⇒ 要看主会话」这条**待落地的意图**(W53N)。
|
||||
*
|
||||
* ## 为什么它必须活在进程里,而不是直接在那次 Intent 里做完
|
||||
* 通知可能在**任何**时刻被点:App 还没起来(冷启动)、已经开着但停在设置页、甚至还没登录。
|
||||
* 而真正能「切到主会话」的只有一处——`ChatHomeScreen` 里那个 `SessionsViewModel`(`select()`),
|
||||
* 它要被组合出来才存在,登录态没定时**根本没有**。所以这里只记一笔「用户要求看主会话」,
|
||||
* 由 `ChatHomeScreen` 在能落地的那一刻取走(与 `ShareIntake` 同一种做法:待落地的意图活在
|
||||
* Compose 与 ViewModel 之外,消费方自己挑时机认领)。
|
||||
*
|
||||
* ## 为什么是 StateFlow 而不是一个布尔
|
||||
* 消费方(`ChatHomeScreen`)在 App 已经开着的时候**已经在组合里**:一个普通布尔量被改成 true
|
||||
* 不会叫醒它,那一次点击就会静默丢掉(用户点了通知,进来还停在设置页)。`StateFlow` 一改值就能
|
||||
* 让订着它的那段组合重跑判据。
|
||||
*
|
||||
* ## 不清理会怎样
|
||||
* 没人消费时它一直是 true(例如点了通知但登录失败)——代价是「重登之后落在主会话」。
|
||||
* 主会话本来就是默认落点(`resolveActiveSession` 在会话没了时也收敛到它),所以这里**刻意不**
|
||||
* 在登出时清掉:留一个不会做错事的意图,好过为了干净而多一条「谁负责清它」的路径。
|
||||
*/
|
||||
class ProactiveTapIntake {
|
||||
|
||||
private val _openMainSession = MutableStateFlow(false)
|
||||
|
||||
/** 是否有一笔待落地的「切到主会话」。消费方拿到 true 后必须调 [consume]。 */
|
||||
val openMainSession: StateFlow<Boolean> = _openMainSession.asStateFlow()
|
||||
|
||||
/**
|
||||
* 从一次启动 / 热启动的 Intent 里认领这条意图(`MainActivity` 的两条入口都走这里)。
|
||||
*
|
||||
* 判据是这个 extra 的**字面量 true**:别的 Intent(分享、桌面图标)里它缺席,什么都不做。
|
||||
*/
|
||||
fun accept(intent: Intent?) {
|
||||
if (intent?.getBooleanExtra(EXTRA_PROACTIVE_OPEN_MAIN_SESSION, false) == true) {
|
||||
requestMainSession()
|
||||
}
|
||||
}
|
||||
|
||||
/** 记下「用户要求看主会话」(与 `accept` 分开,是为了这一半能脱离 Android 单测)。 */
|
||||
fun requestMainSession() {
|
||||
_openMainSession.value = true
|
||||
}
|
||||
|
||||
/** 取走这笔意图(只有真正落地了「切到主会话 + 回到聊天页」的那一次才调)。 */
|
||||
fun consume() {
|
||||
_openMainSession.value = false
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,20 @@ package top.yeij.cyrene.app
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||
import top.yeij.cyrene.domain.chat.DocumentDelivery
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.share.PendingShareHolder
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
import top.yeij.cyrene.domain.share.SharedDocument
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
@@ -26,13 +32,39 @@ import java.util.UUID
|
||||
* ## 图片为什么在收到的那一刻就落盘
|
||||
* `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页)
|
||||
* 之后再去读,拿到的多半是 `SecurityException` / 打不开。所以字节在这里立刻拷进 `cacheDir`,
|
||||
* 之后一律用本端自己的 `file://` 路径——**外部 Uri 只在这个方法里出现一次**。
|
||||
* 之后一律用本端自己的 `file://` 路径——**外部 Uri 只在这个方法里出现一次**。文件(非图片)同理。
|
||||
*
|
||||
* ## 分流:图片还是文件
|
||||
* 逐条 URI 看 `ContentResolver.getType`(判定见 [top.yeij.cyrene.domain.share.classifySharedStream]):
|
||||
* MIME 以 `image/` 开头走既有图片路,**其余一律走文档路**。此前一律当图片拷、当图片解码,
|
||||
* 一份 `.md` 因此只有两种结局——「图片处理失败」或者被当成一张读不出来的图。
|
||||
*
|
||||
* ⚠️ **Manifest 刻意不声明全通配或文本通配 MIME**:后端**明令拒绝** `text/html` / SVG / XML /
|
||||
* 压缩包 / 脚本,放宽声明会造出一堆「点进去必然失败」的入口。所以这里能接到的**文件**只有
|
||||
* 系统按 `text/plain` 投过来那些(`.txt` / `.md` / `.log` 这类文件管理器确实会报 `text/plain`);
|
||||
* 报成 `text/markdown` 之类的分享**根本不会**出现在 Cyrene 的分享菜单里。
|
||||
*
|
||||
* ⚠️ **provider 不报 MIME(`getType` 回 null)时按文档路走**,这是刻意的:本端不猜类型,
|
||||
* 猜错等于替用户把一个合规文件变成一次失败;代价是「图片但 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])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
|
||||
* 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。
|
||||
* 目录因此以「一次进程生命周期内的分享次数 × 4」为界,另有系统的 cache 回收兜底。
|
||||
* 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,另加 [stageTextAsDocument]
|
||||
* 为超长正文落下的那一份 `.txt`),另有系统的 cache 回收兜底。
|
||||
*/
|
||||
object ShareIntake {
|
||||
|
||||
@@ -64,30 +96,63 @@ object ShareIntake {
|
||||
/**
|
||||
* 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。
|
||||
*
|
||||
* 提取是纯函数([ShareRules.extract]),拷图是重活,所以在 IO 线程做:`content://` 的读
|
||||
* 提取是纯函数([ShareRules.extract]),拷字节是重活,所以在 IO 线程做:`content://` 的读
|
||||
* 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。
|
||||
*/
|
||||
fun accept(context: Context, intent: Intent) {
|
||||
val appContext = context.applicationContext
|
||||
// 四个 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),
|
||||
stream = intent.streamUri(),
|
||||
streams = intent.streamUris(),
|
||||
// 正文走回落链: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,
|
||||
streamStrings = streamExtras.stringForms,
|
||||
// 「里面躺着值」这件事只有这里知道(纯函数看不到原始值):形态认不出的那种也算带着内容,
|
||||
// 否则「分享点下去什么都没发生」会从那条缝里回来。
|
||||
streamPresent = rawStream != null,
|
||||
textPresent = textExtras.present,
|
||||
clipUris = intent.clipUris(),
|
||||
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
|
||||
mimeOf = { uri ->
|
||||
runCatching { context.contentResolver.getType(Uri.parse(uri)) }.getOrNull()
|
||||
},
|
||||
)
|
||||
if (extracted.isEmpty && extracted.droppedImages == 0) return
|
||||
// 没什么值得往下走的就到此为止(判据是 [SharedContent.worthDelivering]:有可落地的内容,
|
||||
// 或者有丢件 / 读不进来要对用户说清的事)。整条分享里什么都没有时静默是对的;而
|
||||
// 「带着内容迹象却一条都没读进来」必须继续往下走——那条告知在下游 `shareNoticeFor` 里等它。
|
||||
if (!extracted.worthDelivering) return
|
||||
|
||||
val appContext = context.applicationContext
|
||||
scope.launch {
|
||||
val dir = shareDir(appContext).apply { mkdirs() }
|
||||
val copied = extracted.images.mapIndexedNotNull { index, uri ->
|
||||
copyToCache(appContext, dir, uri, index)
|
||||
}
|
||||
val documents = extracted.documents.mapIndexedNotNull { index, document ->
|
||||
copyDocumentToCache(appContext, dir, document, index)
|
||||
}
|
||||
holder.put(
|
||||
extracted.copy(
|
||||
images = copied,
|
||||
documents = documents,
|
||||
// 拷不动的那些如实计入「没发送」:宁可界面上说一句,也不要静默少一张。
|
||||
failedImages = extracted.failedImages + (extracted.images.size - copied.size),
|
||||
failedDocuments = extracted.failedDocuments + (extracted.documents.size - documents.size),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -96,13 +161,112 @@ object ShareIntake {
|
||||
/**
|
||||
* 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。
|
||||
*
|
||||
* 出队是**唯一的认领方式**:消费它的地方会被重建(重组 / 切会话 / Activity 重建),
|
||||
* 所以「已经发过了」这件事不能记在消费方身上(见 [PendingShareHolder])。
|
||||
* 出队是**唯一的认领方式**,而它在这里是原子的:两个消费者同时来也只会各拿各的一条
|
||||
* (判据见 [PendingShareHolder.take])。而「**已经认领过了**」这件事此后记在认领它的那一格上
|
||||
* ——`ChatViewModel` 的 [top.yeij.cyrene.ui.chat.ChatUiState.shareConfirm]。那一格活在
|
||||
* `AppContainer` 那一层(`core.session.ChatSessionSlots` 的 `ViewModelStore`),比组合与
|
||||
* Activity 都活得久,所以转屏 / 深浅色切换重建之后**不会再取第二遍**,也不会把队列里下一条顶掉:
|
||||
* 认领的闸门在 `ChatViewModel.claimSharedContent` 里,且在调用本函数**之前**。
|
||||
*/
|
||||
fun take(): SharedContent? = holder.take()
|
||||
|
||||
private fun shareDir(context: Context): File = File(context.cacheDir, DIR_NAME)
|
||||
|
||||
/**
|
||||
* 把一段**分享进来的超长正文**落成 cache 里的 `.txt`,返回可当附件用的待发文档;写不进去回 `null`。
|
||||
*
|
||||
* ## 为什么走本对象
|
||||
* 落成附件的那份字节必须有个**本端的本地地址**才能进既有发送链(芯片要显示它、上传要按 URI
|
||||
* 现读字节、发送失败回滚后用户还可能要点开看):`DraftDocument` 只带引用不带字节。而写进
|
||||
* 「分享缓存」这个目录、跟着同一条清理规则(进程启动整目录删一次)是最省事也最一致的做法——
|
||||
* 与外部分享进来的文件**同一处落地、同一条生命周期**,不另造一个没人回收的目录。
|
||||
* 本函数只负责「落盘 + 报一个可用的引用」,**不判断**要不要转(那是
|
||||
* [DocumentDelivery.planSharedText] 的事)。
|
||||
*
|
||||
* 文件名用 [ShareRules.documentCacheFileName] 生成(**只由本端 token 与序号拼路径**,
|
||||
* 外部字符串一个字符都进不了文件名),但对**服务端**报的名字是
|
||||
* [DocumentDelivery.TEXT_DOCUMENT_FILE_NAME] ——服务端只取扩展名(白名单里的 `.txt`),
|
||||
* 其余部分会被内容寻址丢弃。
|
||||
*
|
||||
* @return 待发文档(含 `file://` 地址、显示名与**真实字节数**);任何一步失败都回 `null`,
|
||||
* 由调用方按「内容一字不丢」的原则退回内联正文(见 `ChatViewModel.sendShared`)。
|
||||
*/
|
||||
suspend fun stageTextAsDocument(context: Context, text: String): DraftDocument? = withContext(Dispatchers.IO) {
|
||||
val staged = runCatching { DocumentDelivery.textAsFile(text) }.getOrNull() ?: return@withContext null
|
||||
val dir = shareDir(context.applicationContext).apply { mkdirs() }
|
||||
val target = File(
|
||||
dir,
|
||||
ShareRules.documentCacheFileName(UUID.randomUUID().toString(), 0, staged.fileName),
|
||||
)
|
||||
try {
|
||||
target.writeBytes(staged.bytes)
|
||||
val size = target.length()
|
||||
if (size > 0L) {
|
||||
DraftDocument(
|
||||
uri = Uri.fromFile(target).toString(),
|
||||
displayName = staged.fileName,
|
||||
sizeBytes = size,
|
||||
)
|
||||
} else {
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 半截文件不能留下:它会被当成一份真附件去读,报出的错会指向错的地方。
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一份非图片分享文件的字节拷进 cache,返回**本端自己的** `file://` 地址 + 显示名 + 真实字节数;
|
||||
* 失败回 `null`。
|
||||
*
|
||||
* 显示名必须先取(拷完之后外部 URI 就只在这个调用栈里了),取不到就回落 URI 最后一段——
|
||||
* 它是**上传部件的名字**,服务端只从中取扩展名(判定白名单),空名字则由仓库层兜
|
||||
* `document.txt`。
|
||||
*/
|
||||
private fun copyDocumentToCache(
|
||||
context: Context,
|
||||
dir: File,
|
||||
document: SharedDocument,
|
||||
index: Int,
|
||||
): SharedDocument? {
|
||||
val parsed = runCatching { Uri.parse(document.uri) }.getOrNull() ?: return null
|
||||
if (parsed.scheme.isNullOrBlank()) return null
|
||||
|
||||
val name = displayNameOf(context, parsed)
|
||||
val target = File(dir, ShareRules.documentCacheFileName(UUID.randomUUID().toString(), index, name))
|
||||
return try {
|
||||
val source = context.contentResolver.openInputStream(parsed) ?: return null
|
||||
source.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
|
||||
val size = target.length()
|
||||
if (size > 0L) {
|
||||
document.copy(uri = Uri.fromFile(target).toString(), displayName = name, sizeBytes = size)
|
||||
} else {
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 半截文件不能留下:下一次发送会把它当一份真文件去读,报出的错会指向错的地方。
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** provider 报的显示名优先;拿不到就回落到 URI 最后一段(都不给就留空,不猜扩展名)。 */
|
||||
private fun displayNameOf(context: Context, uri: Uri): String {
|
||||
val fromProvider = runCatching {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (index < 0 || cursor.isNull(index)) null else cursor.getString(index)
|
||||
}
|
||||
}.getOrNull()
|
||||
return fromProvider?.takeIf { it.isNotBlank() }
|
||||
?: DocumentAttachments.fallbackDocumentName(uri.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个外部 URI 的字节拷进 cache,返回本端自己的 `file://` URI;失败回 `null`。
|
||||
*
|
||||
@@ -137,15 +301,136 @@ object ShareIntake {
|
||||
}
|
||||
}
|
||||
|
||||
private fun Intent.streamUri(): String? =
|
||||
getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)?.toString()
|
||||
/**
|
||||
* 一个 extra 的**原始值**(四个 extra 共用),取不到回 `null`。
|
||||
*
|
||||
* ## 为什么读原始值而不是 `getXxxExtra`
|
||||
* `Bundle.get` 在 API 33 起被打上废弃:它绕过类型安全。这里要的正是「绕过类型安全」——要判的
|
||||
* 就是「这个值到底是什么形态」。逐类型的 `getXxxExtra` 遇到别的形态只会往 logcat 打一句
|
||||
* `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.rawExtra(key: String): Any? = runCatching { extras?.get(key) }.getOrNull()
|
||||
|
||||
private fun Intent.streamUris(): List<String> =
|
||||
getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java).orEmpty().map { it.toString() }
|
||||
|
||||
/** `ClipData` 里的 URI(部分 App 只填它);纯文本条目的 URI 为空,天然被过滤掉。 */
|
||||
private fun Intent.clipUris(): List<String> {
|
||||
/**
|
||||
* `ClipData` 各条目的 URI(部分 App 只填它);纯文本条目的 URI 是 `null`。
|
||||
*
|
||||
* 这里刻意**保留**没有 URI 的条目(用 `null` 占位)而不是就地滤掉:条目本身就是「这次分享带着
|
||||
* 内容」的证据——只有纯文本条目时本端一个字节都读不到(本端不读 `ClipData` 里的文本),那种
|
||||
* 情形必须能说出口,见 [SharedContent.contentUnreadable]。
|
||||
*/
|
||||
private fun Intent.clipUris(): List<String?> {
|
||||
val clip = clipData ?: return emptyList()
|
||||
return (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri?.toString() }
|
||||
return (0 until clip.itemCount).map { clip.getItemAt(it).uri?.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXTRA_STREAM` 一条**原始值** ⇒ 里面能读出来的地址,按形态分成两组。
|
||||
*
|
||||
* ## 为什么读原始值而不是挨个 `getXxxExtra` 去问
|
||||
* `EXTRA_STREAM` 在真实世界里有三种形态:规范推荐的 `Uri`(多选时 `ArrayList<Uri>`)、`String[]`、
|
||||
* `ArrayList<String>`。后两种(历史遗留 / 跨平台壳应用常见)此前**完全认不出**:`getParcelableExtra`
|
||||
* 对 `String[]` 只会往 logcat 打一句
|
||||
* `ClassCastException: Cannot cast [Ljava.lang.String; to android.net.Uri` 然后回 `null`,
|
||||
* `getParcelableArrayListExtra` 同理——于是整次分享「什么都没发生」,连一句告知都没有。读原始值再按
|
||||
* 类型分派是一次判完三种形态,顺带也不再往 logcat 里丢那两行噪声(`Bundle.get` 不做类型检查,
|
||||
* 正是这里需要的;`getXxxExtra` 那一套是查完再 cast,查不中就把值扔了)。
|
||||
*
|
||||
* ## 形态决定信任级别
|
||||
* - [uriForms]:`Uri`,以及数组 / 列表里的 `Uri`。对方是把 URI **对象**写进去的,这是规范路径;
|
||||
* 本端维持既有口径不动(不看 scheme)。
|
||||
* - [stringForms]:`String` / `CharSequence` 及其数组 / 列表。这是本端新开的一条读入口,所以开得窄
|
||||
* ——能不能用由 [ShareRules.stringStreamUri] 逐条判(只认 `content://`)。
|
||||
*
|
||||
* 认不出形态的原始值(别的 `Parcelable` / 数字 / 映射……)**不猜**:两边都不收。
|
||||
*/
|
||||
internal data class ShareStreamExtras(
|
||||
val uriForms: List<String>,
|
||||
val stringForms: List<String>,
|
||||
)
|
||||
|
||||
/** 见 [ShareStreamExtras]:把 `EXTRA_STREAM` 的原始值按形态摊平成两组地址(纯逻辑,可单测)。 */
|
||||
internal fun shareStreamExtrasOf(raw: Any?): ShareStreamExtras {
|
||||
val uriForms = mutableListOf<String>()
|
||||
val stringForms = mutableListOf<String>()
|
||||
|
||||
fun collect(value: Any?) {
|
||||
when (value) {
|
||||
null -> Unit
|
||||
is Uri -> uriForms += value.toString()
|
||||
// 含 String。`Spanned` 之类也走这一条:它照样要过 scheme 白名单,不是「因为它是
|
||||
// CharSequence 就信」。
|
||||
is CharSequence -> stringForms += value.toString()
|
||||
is Array<*> -> value.forEach { collect(it) }
|
||||
is Iterable<*> -> value.forEach { collect(it) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
collect(raw)
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
package top.yeij.cyrene.core.keepalive
|
||||
|
||||
/**
|
||||
* 「后台保持连接」该不该跑(**纯函数**,UI 与测试共用)。
|
||||
*
|
||||
* 判据单独拎出来的理由与先例一致(`ui.sessions.silentRefreshStep`、`ui.chat.reconnectPull`):
|
||||
* 起服务那半边要 `Context`、要系统 API,在 JVM 单测里起不来;而「什么时候该跑」只有几个布尔,
|
||||
* 能穷举、能断言。糊进 UI 之后就再也说不清「登出之后到底还会不会留着一条前台服务」——
|
||||
* 那恰恰是这个功能最容易被写错的一半。
|
||||
*
|
||||
* 三个条件缺一不可,每一个都有自己的理由:
|
||||
* - [enabled]:用户的选择,**默认关**。没打开就什么都不该常驻。
|
||||
* - [loggedIn]:实时连接是登录态的产物(`SessionsViewModel` 在登出时关掉那条共享连接)。
|
||||
* 没有连接还留一条前台服务,就只剩「白占一条通知 + 一点电」。
|
||||
* - [notificationsAllowed]:`POST_NOTIFICATIONS` 被拒时前台服务**照样能跑**(系统不再拦它),
|
||||
* 但那就等于**背着用户常驻一个不可见的进程**。本功能卖的就是「我明说我在跑」,
|
||||
* 偷偷跑比不跑更糟——所以权限被拒判「不该跑」,由设置页那行字如实说出来。
|
||||
*
|
||||
* minSdk 33 ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,不存在「低版本不用问」的那一档。
|
||||
*/
|
||||
internal fun shouldKeepAliveRun(
|
||||
enabled: Boolean,
|
||||
loggedIn: Boolean,
|
||||
notificationsAllowed: Boolean,
|
||||
): Boolean = enabled && loggedIn && notificationsAllowed
|
||||
|
||||
/** 这一刻该对那条前台服务做什么([keepAliveAction] 的取值)。 */
|
||||
internal enum class KeepAliveAction {
|
||||
/** 该在跑:起服务。重复起是安全的,见 [keepAliveAction]。 */
|
||||
Start,
|
||||
|
||||
/** 不该在跑:停服务。对一条没在跑的服务喊停同样是安全的。 */
|
||||
Stop,
|
||||
|
||||
/** 什么都不做:**此刻还不该有主张**(登录态未定,见 [keepAliveAction])。 */
|
||||
Leave,
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一刻该对那条前台服务做什么(**纯函数**,判据本体是 [shouldKeepAliveRun])。
|
||||
*
|
||||
* ## 为什么不只回一个布尔
|
||||
* 只回「该不该跑」的话,调用方每次都得转成一次 `start` 或 `stop`,而**启动自举那一段**
|
||||
* (`SessionState.Unknown`)两种都不该做:冷启动时进程里根本没有服务,但**热启动**(Activity
|
||||
* 重建、进程还活着)时上一次那条还好端端跑着,此时若按「还没登录 ⇒ 停」收掉它,用户会看到
|
||||
* 「通知消失 → 自举完成 → 通知又回来」这一闪,而中间那段(App 刚被切到后台时)进程正好没有任何
|
||||
* 保护。所以那一档是 [KeepAliveAction.Leave]。
|
||||
*
|
||||
* ## 为什么**不**看一眼「现在跑着没有」
|
||||
* 那样能省掉几回重复调用,但代价是引入一份会漂移的进程内状态:系统完全可能**在进程还活着的前提下**
|
||||
* 把服务收掉而不走 `onDestroy`(比如用户在系统的「正在运行的应用」里按停),于是那份「跑着」的记忆
|
||||
* 停在 true,下一轮判据就会「Leave」——**该起的时候一声不响地没起**,而这正是本功能最不能出的错。
|
||||
* 重复调用则没有任何代价:`startForegroundService` 打到一条已经在跑的服务上只是刷新同一条
|
||||
* (同 id、静默渠道的)通知,`stopService` 打到没在跑的服务上是空操作。
|
||||
*
|
||||
* @param enabled 设置页那个开关(持久化在 `AppPrefs.keepAliveEnabled`)。
|
||||
* @param sessionKnown 登录态**已经定了**(`Unknown` 之外的一切)。
|
||||
* @param loggedIn 现在处于登录态。
|
||||
* @param notificationsAllowed `POST_NOTIFICATIONS` 已授权。
|
||||
*/
|
||||
internal fun keepAliveAction(
|
||||
enabled: Boolean,
|
||||
sessionKnown: Boolean,
|
||||
loggedIn: Boolean,
|
||||
notificationsAllowed: Boolean,
|
||||
): KeepAliveAction = when {
|
||||
// 开关关着:没有第二种解释,收掉。**不等登录态**——用户刚表达过「不要」,用不着等自举。
|
||||
!enabled -> KeepAliveAction.Stop
|
||||
|
||||
// 登录态未定(启动自举的那一段):这时谁都说不准该不该跑,别动。
|
||||
!sessionKnown -> KeepAliveAction.Leave
|
||||
|
||||
// 明确登出:连接都没了,留着没意义。开关本身不动(`AppSettings` 是 App 级的、不随登出清空,
|
||||
// 下次登录仍按用户当初的选择来)。这里读的是**登录态**而不是「用没用上连接」——本服务不碰连接
|
||||
// (见 [KeepAliveService]),它对连接的了解就只有「现在有没有一个登录着的用户」这一件事。
|
||||
!loggedIn -> KeepAliveAction.Stop
|
||||
|
||||
// 到这儿只剩「开关开着 + 已登录」这一种情形,跑不跑由通知权限说话。
|
||||
shouldKeepAliveRun(enabled, loggedIn, notificationsAllowed) -> KeepAliveAction.Start
|
||||
|
||||
else -> KeepAliveAction.Stop
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package top.yeij.cyrene.core.keepalive
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import top.yeij.cyrene.BuildConfig
|
||||
import top.yeij.cyrene.app.MainActivity
|
||||
|
||||
/** 通知渠道 id。恒定不变:改了它等于换一条渠道,用户对旧渠道做的静音设置会失效。 */
|
||||
internal const val KEEP_ALIVE_CHANNEL_ID = "keep_alive"
|
||||
|
||||
/**
|
||||
* 渠道重要级。**只许是 LOW / MIN**([NotificationManager.IMPORTANCE_LOW] = 不响铃、不震动、
|
||||
* 不弹横幅):这是「保活」不是「提醒」,用户没要求被叫住。
|
||||
*
|
||||
* 注意重要级是**渠道创建时定死的**:应用后改不了(只有用户能改),所以这个值必须是最终值。
|
||||
*/
|
||||
internal const val KEEP_ALIVE_CHANNEL_IMPORTANCE = NotificationManager.IMPORTANCE_LOW
|
||||
|
||||
/** 通知 id。常驻的那一条始终用它,重复起服务只更新不叠加。 */
|
||||
internal const val KEEP_ALIVE_NOTIFICATION_ID = 1
|
||||
|
||||
private const val LOG_TAG = "KeepAliveService"
|
||||
|
||||
/**
|
||||
* `POST_NOTIFICATIONS` 授没授(minSdk 33 ⇒ 它恒是运行时权限)。
|
||||
*
|
||||
* 它既是 [shouldKeepAliveRun] 的入参,也是设置页那行字要说的事,所以两边共用这一处口径。
|
||||
*/
|
||||
internal fun Context.notificationsAllowed(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* 「后台保持连接」的前台服务。
|
||||
*
|
||||
* ## 它的职责只有一条:让进程活着
|
||||
* 实时连接是**进程级共享**的那一条(`AppContainer.chatSocket`),所有权在 `SessionsViewModel`
|
||||
* ——登录连、登出关。所以本服务**绝不**去 `connect()` / `close()` 它,也**不订阅任何帧**:
|
||||
* 换了它的死活不等于换了连接的死活,两者各有各的主人。
|
||||
*
|
||||
* 它要挡的是 Android 12+ 对 cached 进程的冻结:进程一旦被冻,那条连接的读写就不再被调度,
|
||||
* 久了连接就断(消息不丢,服务端会落库,回前台靠 history + `after` 补洞恢复,但那就不是
|
||||
* 「保持连接」了)。前台服务把本进程从 cached 那一档里抬出来,这件事就到此为止。
|
||||
*
|
||||
* ## 起停只有一个主人
|
||||
* [keepAliveAction] 判、`CyreneApp` 那个 `LaunchedEffect` 执行(全 App 唯一一处):
|
||||
* 谁都不许自己在别处 start / stop 它,否则「登出后还留着一条服务」这类不一致没有一处说得清。
|
||||
*
|
||||
* ## 它被系统杀掉怎么办
|
||||
* [onStartCommand] 回 `START_STICKY`:被内存压力回收后系统会重建它。用户主动关开关走的是
|
||||
* [stop],那是显式停止,粘性不会把它拉回来。
|
||||
*/
|
||||
class KeepAliveService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
KEEP_ALIVE_NOTIFICATION_ID,
|
||||
buildNotification(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 15(API 35)起,`dataSync` 型前台服务在 24 小时内累计跑满 6 小时,系统会回调这里,
|
||||
* 并给几秒钟让应用自己收手——**不接管就会 ANR 崩掉整个 App**(本端 targetSdk 37,躲不掉)。
|
||||
*
|
||||
* 所以这里照办:`stopSelf` 收掉自己,进程回到普通后台状态。刻意**不绕**这条限制
|
||||
* (绕它要么换前台服务类型、要么加心跳,都超出这个功能的边界)。代价如实说:同一台设备
|
||||
* 24 小时内累计只能保活 6 小时;额度用完之后能不能再起、什么时候能再起由系统说了算,
|
||||
* 本端不替它猜(这条限制的真机表现留给验收)。
|
||||
*/
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
stopSelf(startId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 常驻通知。文案**刻意克制**:它只是为了让「我在后台跑着」这件事在系统里是可见的
|
||||
* (前台服务必须有一条通知,这是本功能对用户的代价,设置页那行字把它写明了)。
|
||||
*
|
||||
* `setSilent` + `setOngoing` + `setOnlyAlertOnce` + 渠道 LOW:不响、不震、不亮屏、划不掉。
|
||||
* `setOnlyAlertOnce` 是给**重复起服务**留的(判据不看「现在跑着没有」,见 [keepAliveAction]):
|
||||
* 第二次 `startForegroundService` 打到同一条通知上时,用户那边必须**一点动静都没有**。
|
||||
*/
|
||||
private fun buildNotification(): Notification {
|
||||
// 点一下回到 App:一条点不动的常驻通知会让人以为卡住了。
|
||||
val open = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, KEEP_ALIVE_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle("保持与昔涟的连接")
|
||||
.setContentText("Cyrene 正在后台维持与昔涟的连接")
|
||||
.setContentIntent(open)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val channel = NotificationChannel(
|
||||
KEEP_ALIVE_CHANNEL_ID,
|
||||
"后台保持连接",
|
||||
KEEP_ALIVE_CHANNEL_IMPORTANCE,
|
||||
).apply {
|
||||
description = "开启「后台保持连接」期间常驻的通知:让系统不要冻结 Cyrene 的进程"
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
}
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun start(context: Context) {
|
||||
// Android 12+ 对「从后台起前台服务」是受限的,系统直接抛异常;这里吞掉它,理由有两条:
|
||||
// ① 这个失败用户既看不懂也无从处置,而把异常放出去就是一次崩溃——崩溃比「这次没起成」
|
||||
// 严重得多;
|
||||
// ② 状态是自愈的:下一个判据触发点(开关 / 登录态变化、下次启动)会再试一次,
|
||||
// 而这次没起成除了「进程暂时少一层保护」之外不留任何后遗症。
|
||||
runCatching { ContextCompat.startForegroundService(context, intent(context)) }
|
||||
.onFailure {
|
||||
if (BuildConfig.DEBUG) Log.d(LOG_TAG, "系统拒绝了这次前台服务启动", it)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(intent(context))
|
||||
}
|
||||
|
||||
private fun intent(context: Context): Intent = Intent(context, KeepAliveService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package top.yeij.cyrene.core.notify
|
||||
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* 进程级的两个「我此刻在哪儿」的事实(W53N):**App 在不在前台**、**聊天页正显示哪个会话**。
|
||||
*
|
||||
* ## 为什么是这一层,而不是 ProcessLifecycleOwner
|
||||
* 本仓没有 `lifecycle-process` 依赖,而为一个「要不要弹通知」的判断新增依赖不值当(主人明令不加)。
|
||||
* 既有的 `MainActivity` 生命周期已经足够说清第一件事——本应用只有**一个** Activity
|
||||
* (`singleTop`),它的 `onStart` / `onStop` 就是「App 有没有在被看」。第二件事由 `ChatScreen`
|
||||
* 的组合报告(组合在 = 那一页在屏幕上,见 [onChatSurface])。两条都在手写 DI 容器
|
||||
* (`app.AppContainer`)那一层汇合——与那条共享连接、`goneSessions` 那几件进程级事实同一层。
|
||||
*
|
||||
* ## 为什么前台用「活着的 Activity 集合」而不是一个布尔
|
||||
* 配置变更(旋转 / 系统深浅色切换)时,**新实例的 `onStart` 会先于旧实例的 `onStop`**:
|
||||
* 一个布尔量在那里必然被旧实例的 `onStop` 记成「后台」,于是用户正看着聊天页,她一句话却弹出
|
||||
* 通知来。按实例登记就没有这个方向性问题——新实例先加进来、旧实例后移出去,集合始终非空。
|
||||
* 集合按 `ConcurrentHashMap` 语义(`Activity` 不重写 `equals` ⇒ 就是**同一实例**),
|
||||
* 且读写可能不在同一线程上(她的话那条通路是在**进程级订阅自己的调度器**上读它的,见
|
||||
* `ProactiveFrameIntake`),所以用并发容器而不是普通 `MutableSet`。
|
||||
*
|
||||
* ## 它**不是**「App 有没有活着」
|
||||
* 后台保活(前台服务)让进程活着、连接继续收帧,而本类在这里恒报「不在前台」——那正是通知要
|
||||
* 出现的那一档。两者是两件事,别把它们揉在一起。
|
||||
*/
|
||||
class AppPresence {
|
||||
|
||||
private val startedActivities: MutableSet<Any> =
|
||||
Collections.newSetFromMap(ConcurrentHashMap<Any, Boolean>())
|
||||
|
||||
/**
|
||||
* 聊天页此刻显示着的会话 id;`null` = 聊天页不在屏幕上(设置页 / 管理页 / 未登录 / 已销毁)。
|
||||
*
|
||||
* 由 [onChatSurface] 写、通知判据读。**刻意不带「前台」的含义**:App 退到后台时这段组合在
|
||||
* Android 上并不会被销毁,这个值会原样留着——前台那一半由 [appInForeground] 单独表达,
|
||||
* 两个事实的合取才是「这条消息此刻会不会正好被用户看到」(见 [shouldNotifyProactive])。
|
||||
*/
|
||||
@Volatile
|
||||
var chatSurfaceSessionId: String? = null
|
||||
private set
|
||||
|
||||
/** App 是否正在被看(有任何一个 Activity 处在 started 之后、stopped 之前)。 */
|
||||
val appInForeground: Boolean
|
||||
get() = startedActivities.isNotEmpty()
|
||||
|
||||
private val _appInForeground = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* [appInForeground] 的**可订阅**形态(W53S 登记的那一格要它)。
|
||||
*
|
||||
* 为什么需要第二条出口:`CyreneApp` 那个「起 / 停前台服务」的 `LaunchedEffect` 的 key 里没有
|
||||
* 通知权限,于是**用户在系统设置里授权后回到 App** 时它不会重跑 —— UI 已经显示「已开」,
|
||||
* 服务却没起。权限本身必须**现读**(不该缓存成状态),能当 key 的是「刚好会在这件事发生时变化」
|
||||
* 的信号:**回到前台**(从系统设置返回必经 `onStart`)。所以这里把「在不在前台」补成一条流。
|
||||
* 判据本身仍然只读 [appInForeground] 那个布尔——两条出口算的是**同一份事实**(同一个集合)。
|
||||
*/
|
||||
val appInForegroundFlow: StateFlow<Boolean> = _appInForeground.asStateFlow()
|
||||
|
||||
/** 一个 Activity 进入「可见」这一档(`onStart`)。 */
|
||||
fun onActivityStarted(owner: Any) {
|
||||
startedActivities.add(owner)
|
||||
_appInForeground.value = appInForeground
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个 Activity 离开「可见」这一档(`onStop`)。
|
||||
*
|
||||
* 只移除**报上来的那一个实例**:照抄成「一停就整集合清空」会把正在起的新实例一起算掉
|
||||
* (那条路径见类注释里的配置变更)。
|
||||
*/
|
||||
fun onActivityStopped(owner: Any) {
|
||||
startedActivities.remove(owner)
|
||||
_appInForeground.value = appInForeground
|
||||
}
|
||||
|
||||
/** 聊天页刚显示(或刚换成)[sessionId];`null` = 它离开了屏幕。 */
|
||||
fun onChatSurface(sessionId: String?) {
|
||||
chatSurfaceSessionId = sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天页此刻显示的是不是 [sessionId] 这个会话(等值比较,不是前缀:`web` ≠ `web:xxx`)。
|
||||
* `sessionId` 为空白时恒假——「没显示任何会话」不等于「显示了一个空 id 的会话」。
|
||||
*/
|
||||
fun isShowingChatSurface(sessionId: String): Boolean =
|
||||
sessionId.isNotBlank() && chatSurfaceSessionId == sessionId
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package top.yeij.cyrene.core.notify
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import top.yeij.cyrene.app.MainActivity
|
||||
import top.yeij.cyrene.core.keepalive.notificationsAllowed
|
||||
|
||||
/**
|
||||
* 「她主动说的话」那条通知的渠道 id —— **弹横幅**的那一条([PROACTIVE_IMPORTANCE])。
|
||||
*
|
||||
* ⚠️ **这里是 `proactive_v2`,不是最初的 `proactive`**:渠道的**重要级一旦创建就改不了**
|
||||
* (Android 的设计 —— 只有用户能改,App 改不动)。2026-10-07 主人明确要求「要横幅」,而把重要级
|
||||
* 从 DEFAULT 提到 HIGH **对已经建过旧渠道的设备不生效** ⇒ 只能换一个新 id,让系统按新重要级
|
||||
* 重新建一条。旧渠道由 [ensureProactiveChannel] 顺手删掉(它的重要级改不动,留着只会让系统设置里
|
||||
* 多一条永远不再出现的条目)。
|
||||
*
|
||||
* **刻意与保活那条分成两个渠道**:保活那条是 LOW(不响不震、用户没要求被叫住),这一条是
|
||||
* [PROACTIVE_IMPORTANCE]。两者混在一条渠道里,就只能在「保活开始响铃」与「她的话不响铃」之间
|
||||
* 二选一——那正是「一个渠道只能是其中一件事」的由来。
|
||||
*
|
||||
* 与 [QUIET_PROACTIVE_CHANNEL_ID] **也是两条**,理由与上面那条一模一样:横幅由渠道重要级决定,
|
||||
* 而重要级建完就改不了 ⇒ 设置页那个「免打扰」开关**不可能**靠运行时改设置实现,只能按开关选一条
|
||||
* 渠道发(见 [proactiveChannelId])。
|
||||
*/
|
||||
internal const val PROACTIVE_CHANNEL_ID = "proactive_v2"
|
||||
|
||||
/**
|
||||
* 「她主动说的话」在**免打扰**下走的那条渠道 id:重要级是 [QUIET_PROACTIVE_IMPORTANCE]
|
||||
* (有提示音、通知栏有条目、**不弹横幅**)。
|
||||
*
|
||||
* 为「免打扰」单独建**第二条**渠道,是因为**重要级建完就改不了**(理由见 [PROACTIVE_CHANNEL_ID])
|
||||
* ——想「只关横幅」就只有换渠道这一条路。
|
||||
*
|
||||
* ⚠️ 刻意**不用** `setSilent(true)`:那是**完全静音**(连提示音都没),而用户要的语义是
|
||||
* 「只在通知栏提示,别在屏幕顶部弹横幅」——两者之间差着一个提示音,不是同一件事。
|
||||
*/
|
||||
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 = "昔涟"
|
||||
|
||||
/**
|
||||
* 渠道名(系统设置的渠道列表里那一行)。与通知标题分开是为了让它说清**这条渠道是什么**,
|
||||
* 而不是重复一遍说话人的名字。
|
||||
*/
|
||||
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。
|
||||
*
|
||||
* 它是本端**手写布尔路由**里的一次「带意图的冷启动 / 热启动」:`MainActivity` 读它 →
|
||||
* `ProactiveTapIntake` 记一笔 → `ChatHomeScreen` 落成一次 `select(主会话)`。刻意**不引导航库**
|
||||
* (本项目没有导航库,路由就是 `ChatHomeScreen` 里那几个布尔量)。
|
||||
*/
|
||||
internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PROACTIVE_OPEN_MAIN_SESSION"
|
||||
|
||||
/**
|
||||
* 弹一条「她主动说的话」的通知。
|
||||
*
|
||||
* 只做四件事:按 [quiet] 选渠道、建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、
|
||||
* 去重与 id 的派生都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)
|
||||
* ——本函数被调用就意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。
|
||||
*
|
||||
* ## 为什么 id 是入参而不是这里恒定一个
|
||||
* 同 id 的 `notify` 是**替换**:恒用一个号就等于「后来的覆盖先来的」——她连说几句,用户只看到
|
||||
* 最后一句。入参那个号由 [proactiveNotificationId] 按消息派生(输入就是去重键),于是同一句
|
||||
* 无论到几次都落在同一条通知上,而不同的句子各占一条。
|
||||
*
|
||||
* ## 免打扰只换渠道,别的一个字节都不动
|
||||
* [quiet] 为真时整条通知除了「发去哪条渠道 / 7.x 的优先级」以外**逐字节不变**:同一个标题、同一
|
||||
* 段正文、同一个通知号、同一次点击行为。用户要的是「别在我正做事的时候盖住屏幕」,不是「别让我
|
||||
* 知道」——所以什么都不该少,只是她的话安静地躺在通知栏里。
|
||||
*
|
||||
* ## 没授通知权限时什么都不做
|
||||
* `minSdk 33` ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,而系统在未授权时**静默丢弃**通知。与其发一条
|
||||
* 必然无声无息的东西,不如在这里就收手(判据复用保活那处 [notificationsAllowed],全 App 只有
|
||||
* 一份「授没授」的口径)。用户之后在设置页授权、或直接开通了保活那个开关,下一句话就能弹出来
|
||||
* ——本函数每次都**现读**权限,不缓存。
|
||||
*
|
||||
* ## 小图标用的是平台资源
|
||||
* 工程里没有为通知准备单色小图标(`@mipmap/ic_launcher` 是彩色的,拿它当通知小图标在通知栏上
|
||||
* 会显示成一块白斑)。所以用平台自带的 `stat_notify_chat`——与保活那条用 `stat_notify_sync` 是
|
||||
* 同一个做法。要换成本应用的图标时,只需换下面这一行。
|
||||
*
|
||||
* @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,
|
||||
quiet: Boolean,
|
||||
) {
|
||||
if (!context.notificationsAllowed()) return
|
||||
|
||||
// 选渠道与建渠道用的是**同一个** spec:两处各自 `if (quiet)` 就会有一条渠道永远建不出来,
|
||||
// 而它的症状是「开了免打扰之后她再也不通知我了」(Android 8+ 对不存在的渠道是静默丢弃)。
|
||||
val channel = proactiveChannelSpec(quiet)
|
||||
ensureProactiveChannel(context, channel)
|
||||
|
||||
// request code 随消息走(`proactiveNotificationId` 的下界远高于保活那条用的 `0`,所以不会
|
||||
// 撞上它):两个 Intent 都是「启动 MainActivity」、没有 action / data / categories ⇒
|
||||
// `Intent.filterEquals` 判它们**相等**,而 `PendingIntent` 的相等判据里**不含 extras**。
|
||||
// 同 request code 时系统直接复用**先建的那一个**,后建这枚的 extra 会被悄悄丢掉。
|
||||
val openApp = PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId,
|
||||
Intent(context, MainActivity::class.java).putExtra(EXTRA_PROACTIVE_OPEN_MAIN_SESSION, true),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, channel.id)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setContentTitle(PROACTIVE_NOTIFICATION_TITLE)
|
||||
.setContentText(text)
|
||||
// 展开态显示完整正文(`setContentText` 那一行放不下 120 字,而截断只解决「过长」,
|
||||
// 解决不了「一行的宽度」)。
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentIntent(openApp)
|
||||
// 点掉即消失:这条通知说的是一次刚发生的事,用户点开就等于看到了。
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
// 横幅:Android 8+ 由**渠道重要级**决定(上面那条 spec 选出来的那条渠道),这一句管的是
|
||||
// 7.x 及更早(那边还没有渠道,优先级就是全部)。两处必须同向——免打扰那一档在这里也得
|
||||
// 降回 DEFAULT,否则老设备上「开着免打扰却照样弹横幅」。
|
||||
.setPriority(if (quiet) NotificationCompat.PRIORITY_DEFAULT else NotificationCompat.PRIORITY_HIGH)
|
||||
.build()
|
||||
|
||||
// 一事一条:号随消息走。撞号会发生,代价与改前一样是「后来的把先来的替换掉」,
|
||||
// 理由与概率写在 `proactiveNotificationId` 的 KDoc 里。
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* 建(或更新)**这一次要用的那一条**渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的
|
||||
* 渠道列表里不会平白多出一条「她的话」。
|
||||
*
|
||||
* ## 为什么只建「这一次要用的那一条」,而不是两条都建
|
||||
* 「免打扰」决定的是**下一条**通知发去哪条渠道,而渠道只需要在 `notify` 之前存在。两条都建的话,
|
||||
* 一个从没开过免打扰、将来也不打算开的用户,系统设置里会平白多出一条他从没见过的「她主动说的话
|
||||
* (免打扰:只在通知栏)」——那是我们替他做的选择,也算脏数据。所以这里只 ensure 被选中的那一条。
|
||||
*
|
||||
* ⚠️ 但**必须在 `notify` 之前** ensure:Android 8+ 上目标渠道不存在时,那条通知是被系统**静默
|
||||
* 丢弃**的(不报错、不打日志、只是什么都没有)。这正是「只建一条」这个省法的代价 —— 它靠
|
||||
* [postProactiveNotification] 里「选渠道」与「建渠道」用的是**同一个** [ProactiveChannelSpec]
|
||||
* 兜住(两处各自 `if (quiet)` 就会有一条渠道永远建不出来,而症状是「开了免打扰之后她再也不
|
||||
* 通知我了」——用户只会以为是她不说话了)。
|
||||
*
|
||||
* ## 另一条渠道**不删**
|
||||
* 它是「这一次没选」,不是「以后不再用」:用户来回切开关时,他在系统设置里对另一条调过的横幅 /
|
||||
* 静音设置得留着(删掉 = 把他的选择一起删掉,还得重新建一条空白的)。唯一会删的是
|
||||
* [LEGACY_PROACTIVE_CHANNEL_ID] —— 那条 id 本端**再也不会发**,而它的重要级又改不动,留着只会
|
||||
* 在系统设置里多一条永远不再出现的条目(用户可能在那儿调半天、却发现怎么调都没变化)。
|
||||
*
|
||||
* 重复调用是幂等的(同 id ⇒ 更新那条渠道,不会建出第二条)。**注意重要级只在第一次创建时生效**
|
||||
* ——它已经在 [PROACTIVE_IMPORTANCE] / [QUIET_PROACTIVE_IMPORTANCE] 上定死(渠道名与描述此后仍
|
||||
* 可以改,重要级改不了)。
|
||||
*
|
||||
* @param spec 这一次要用的那条渠道(由 [proactiveChannelSpec] 按「免打扰」开关选出来)。
|
||||
*/
|
||||
private fun ensureProactiveChannel(context: Context, spec: ProactiveChannelSpec) {
|
||||
val channel = NotificationChannel(spec.id, spec.name, spec.importance).apply {
|
||||
description = spec.description
|
||||
setShowBadge(true)
|
||||
}
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
// 旧的 DEFAULT 渠道删掉:重要级改不动,留着只会在系统设置里多一条永远不再出现的条目
|
||||
// (用户可能在那儿调半天、却发现怎么调都没变化)。删一条不存在的渠道是幂等的。
|
||||
manager.deleteNotificationChannel(LEGACY_PROACTIVE_CHANNEL_ID)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package top.yeij.cyrene.core.notify
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
|
||||
/**
|
||||
* 一帧「她主动说的话」进到本类之后**发生了什么**(纯判定的结果,可单测)。
|
||||
*
|
||||
* 分成五档而不是一个布尔,是为了让「为什么这次没弹」在日志与测试里都能一眼看穿:这五条各自
|
||||
* 对应一个完全不同的理由,而在通知这一档上,「没弹」与「弹了」一样需要说得清。
|
||||
*/
|
||||
enum class ProactiveNotifyOutcome {
|
||||
|
||||
/** 弹了。 */
|
||||
Posted,
|
||||
|
||||
/** 用户正看着主会话(前台 ∧ 聊天页显示主会话):她的话就出现在他眼前,不打扰。 */
|
||||
SeenOnScreen,
|
||||
|
||||
/** 同一条已经到达过(实时帧与落库帧两条路,或服务端重推)。 */
|
||||
Duplicate,
|
||||
|
||||
/** 帧里没有可显示的正文(坏帧 / 落库失败的空帧)。 */
|
||||
NothingToSay,
|
||||
|
||||
/**
|
||||
* 不属主会话。她主动说的话落的是主会话(服务端 `history.MainSessionID`);帧上若明说了别的
|
||||
* 会话,那不是这件事,绝不弹(宁可漏,也不要把别处的消息说成是她在找我说话)。
|
||||
*/
|
||||
OtherSession,
|
||||
}
|
||||
|
||||
/**
|
||||
* 「她主动说的话」的通路(W53N):**判据 + 去重 + 落地**收在一处,Android 那一步由 [post] 注入。
|
||||
*
|
||||
* ## 谁把帧喂进来(W57 起)
|
||||
* 后端两条路(实时帧 `proactive_message`、落库帧 `message_persisted{proactive:true}`)在客户端
|
||||
* 汇于一处:**进程级**的帧消费点 `app.ProactiveFrameIntake` 订着那条共享连接,把这两种帧折成下面
|
||||
* [onProactiveFrame] 的四个入参喂过来(同一条消息无论走哪条路、到几次,都只经过这一处)。
|
||||
* 本类因此只是一个**纯逻辑 + 一个回调**,不订阅任何东西、不碰连接、不认识 Android——测试里传个
|
||||
* 假的 [post] 就能把它整条驱动起来。
|
||||
*
|
||||
* ## 它绝不碰连接
|
||||
* 连接的所有权在登录态(`SessionsViewModel` 那处 `LoggedIn → connect()`)。本类只在帧**已经到达
|
||||
* 之后**做判断——帧没到就没有任何事发生,它不会去重连、不会去补拉、不会去订阅。
|
||||
*
|
||||
* ## 一次帧到这里的顺序(每一步的顺序都有理由)
|
||||
* 1. [ProactiveNotifyOutcome.OtherSession]:不是主会话,连账本都不必碰;
|
||||
* 2. [ProactiveNotifyOutcome.NothingToSay]:先看有没有可显示的字。**这一步不记账**——同一句话的
|
||||
* 另一条路上正文可能是齐的(实时帧空、落库帧有字的形状漂移),记了就会把那条能弹的挡成重复;
|
||||
* 3. [ProactiveNotifyOutcome.Duplicate]:记账与查重。**先记账再判「会不会被看到」**,因为「用户
|
||||
* 正看着」那一次也算这条已经到达:他当场看过了,另一条路再补一帧时不该为一句**看过的话**弹
|
||||
* 通知(他这会儿可能已经切走了);
|
||||
* 4. [ProactiveNotifyOutcome.SeenOnScreen] / [ProactiveNotifyOutcome.Posted]:最后才问时机。
|
||||
*
|
||||
* @param presence 「App 在不在前台 / 聊天页显示着谁」,由 `MainActivity` 与 `ChatScreen` 报告。
|
||||
* @param post 真正的落地动作(Android 那边是建渠道 + `notify`)。第二个入参是**这条消息派生出来的
|
||||
* 通知 id**(见 [proactiveNotificationId]):落地侧必须用它,别再恒用一个号——恒用一个号时
|
||||
* `notify` 是替换,她连说几句就只剩最后一句。测试传一个记录用的假实现即可。
|
||||
* @param mainSessionId 主会话 id。默认取 [SessionSummaryDto.MAIN_SESSION_ID],测试可换。
|
||||
*/
|
||||
class ProactiveNotifier(
|
||||
private val presence: AppPresence,
|
||||
private val post: (text: String, notificationId: Int) -> Unit,
|
||||
private val mainSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
|
||||
private val recent: RecentProactiveNotices = RecentProactiveNotices(),
|
||||
) {
|
||||
|
||||
/**
|
||||
* 一帧到了(实时帧与落库帧**共用**这一处,所以同一条消息无论到几次都只弹一次)。
|
||||
*
|
||||
* @param sessionId 帧上的会话归属;`null` / 空白 = 帧壳没给(实时帧就是这样:服务端构造它时
|
||||
* 不设 `sessionId`)⇒ 按「无法判定」放行,由 [mainSessionId] 那条事实兜住归属。
|
||||
* @param id 落库行 id(实时帧的 `data.id` / 落库帧的 `message.id`)——两条路的公共键。
|
||||
* @param messageId IM 侧消息号(实时帧才有;落库失败那一档它是最后的服务端键)。
|
||||
* @param timestamp 服务端给的时间**字面量**(只作去重兜底,排序与本类无关)。
|
||||
* @param content 她的原话(原始文本,标记由 [proactiveNotificationText] 剥)。
|
||||
*/
|
||||
fun onProactiveFrame(
|
||||
sessionId: String?,
|
||||
id: String?,
|
||||
messageId: String?,
|
||||
timestamp: String?,
|
||||
content: String,
|
||||
): ProactiveNotifyOutcome {
|
||||
if (!sessionId.isNullOrBlank() && sessionId != mainSessionId) {
|
||||
return ProactiveNotifyOutcome.OtherSession
|
||||
}
|
||||
|
||||
val text = proactiveNotificationText(content) ?: return ProactiveNotifyOutcome.NothingToSay
|
||||
|
||||
val key = proactiveNotifyKey(id, messageId, timestamp, text)
|
||||
val firstArrival = recent.markIfNew(key)
|
||||
if (!firstArrival) return ProactiveNotifyOutcome.Duplicate
|
||||
|
||||
// 判据只回答「弹不弹」,「用户会不会正好看到」是它的反面——别在接线层另写一遍这个取反
|
||||
// (两份口径早晚会分叉,而这里分叉的代价是「她的话在用户眼前弹出来」)。
|
||||
val seenOnScreen = !shouldNotifyProactive(
|
||||
appInForeground = presence.appInForeground,
|
||||
viewingTargetSession = presence.isShowingChatSurface(mainSessionId),
|
||||
)
|
||||
if (seenOnScreen) return ProactiveNotifyOutcome.SeenOnScreen
|
||||
|
||||
// id 与去重键同源,正是为了让「同一条 ⇒ 同一个号」与「同一条 ⇒ 只弹一次」永远不会分叉。
|
||||
post(text, proactiveNotificationId(key))
|
||||
return ProactiveNotifyOutcome.Posted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package top.yeij.cyrene.core.notify
|
||||
|
||||
import kotlin.math.absoluteValue
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
|
||||
/**
|
||||
* 「她主动说的话」该不该弹一条系统通知 —— 纯函数(W53N)。
|
||||
*
|
||||
* 主人对时机的原话是「**App 不在前台,或者不在聊天页**」。写成一个判据(而不是直接写成
|
||||
* `!viewing`)是为了让两个事实**各自**可见、可穷举、可测试:它们是两个方向完全不同的东西,
|
||||
* 合成一行以后「为什么这一档不弹」就再也说不清了。
|
||||
*
|
||||
* 语义(**判断标准只有一条:这条消息此刻会不会正好被用户看到**):
|
||||
*
|
||||
* | [appInForeground] | [viewingTargetSession] | 结论 | 场景 |
|
||||
* |---|---|---|---|
|
||||
* | true | true | **不弹** | 用户正看着主会话,她的话就出现在他眼前 |
|
||||
* | true | false | 弹 | 用户在看设置页 / 别的会话(甚至另一个会话的聊天页) |
|
||||
* | false | false | 弹 | App 退到后台 / 熄屏(进程被保活着,帧还在到) |
|
||||
* | false | true | 弹 | **Android 上真实存在**:Activity `onStop` 之后组合并没有销毁,
|
||||
* 聊天页仍「显示」着主会话。此时的用户根本看不见它,所以照弹 |
|
||||
*
|
||||
* 最后一行是「两个事实不许互相顶替」的那一半:只看聊天页显示着谁,就会在 App 退到后台以后
|
||||
* 静默漏掉**所有**她的话。
|
||||
*
|
||||
* @param appInForeground 此刻有没有 Activity 在「可见」这一档(见 [AppPresence.appInForeground])。
|
||||
* @param viewingTargetSession 聊天页此刻显示的是不是**她说这句话的那个会话**。注意它**不含**
|
||||
* 「前台」的含义(见 [AppPresence.chatSurfaceSessionId])——它只说「屏幕上摆着哪一页」。
|
||||
*/
|
||||
fun shouldNotifyProactive(
|
||||
appInForeground: Boolean,
|
||||
viewingTargetSession: Boolean,
|
||||
): Boolean = !(appInForeground && viewingTargetSession)
|
||||
|
||||
/**
|
||||
* 通知正文的长度上限(字符)。超出的部分截断加 `…`。
|
||||
*
|
||||
* 取值理由:通知在折叠态只显示一行(约 20~30 字),展开态(`BigTextStyle`)能显示四五行。
|
||||
* 这个上限是**展开态**的预算——正文不砍到一行那么短(那样会看不出她在说什么),但也不许整屏
|
||||
* 铺开。真正的长文在聊天页里一字不少。
|
||||
*/
|
||||
const val PROACTIVE_NOTIFICATION_MAX_CHARS = 120
|
||||
|
||||
/**
|
||||
* 去重账本的容量。同一条她的话最多两条路会到(实时帧 + 落库帧),容量只需要覆盖「最近一小段」;
|
||||
* 取 64 是给「她连说几句 + 两条路交错」留的余量,同时**有界**——进程活一整天也不会涨。
|
||||
*/
|
||||
const val RECENT_PROACTIVE_CAPACITY = 64
|
||||
|
||||
/**
|
||||
* 最近**已经到达过**的 proactive 消息的键(进程级、容量有限、先进先出淘汰)。
|
||||
*
|
||||
* ## 为什么必须有它
|
||||
* 同一条消息会走**两条**路到本端:实时帧 `proactive_message`(带**落库行** `id`)与落库帧
|
||||
* `message_persisted{proactive:true}`(带 `message.id`,同一个值)。不去重就是同一条消息弹两次。
|
||||
*
|
||||
* ## 为什么记的是「到达过」而不是「弹过」
|
||||
* 因为「用户正看着主会话」那一次也要记:他当场看过这条了,随后另一条路再补一帧时**不该**为一句
|
||||
* 已经看过的话弹通知(他这会儿可能已经切到别的会话了)。两个方向都要求同一件事:**先到的那一次
|
||||
* 说了算**。判据见 [ProactiveNotifier.onProactiveFrame]。
|
||||
*
|
||||
* 有界:超过容量按插入顺序丢掉最老的(`LinkedHashSet` 的迭代顺序就是插入顺序)。
|
||||
* 只活在本进程里,不持久化——重启之后最多重复弹一条「刚刚到达过、还没被看过」的话,那比
|
||||
* 为一个通知判据在磁盘上留一份状态诚实得多。
|
||||
*/
|
||||
class RecentProactiveNotices(private val capacity: Int = RECENT_PROACTIVE_CAPACITY) {
|
||||
|
||||
private val seen = LinkedHashSet<String>()
|
||||
|
||||
/**
|
||||
* 记下这个键,并回「它是不是**第一次**来」。返回 false = 这条已经到达过,调用方必须闭嘴。
|
||||
*
|
||||
* 空键恒回 false(「没有键」不是一个可去重的身份,绝不把它当成一条新消息的凭据)。
|
||||
*/
|
||||
@Synchronized
|
||||
fun markIfNew(key: String): Boolean {
|
||||
if (key.isBlank()) return false
|
||||
if (!seen.add(key)) return false
|
||||
while (seen.size > capacity.coerceAtLeast(1)) {
|
||||
val oldest = seen.firstOrNull() ?: break
|
||||
seen.remove(oldest)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 账本里现有多少个键(测试与诊断用)。 */
|
||||
@Synchronized
|
||||
fun size(): Int = seen.size
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条 proactive 消息的去重键(纯函数)。
|
||||
*
|
||||
* 按**服务端给的 id 优先级**取,最后才退到正文:
|
||||
* 1. 落库行 id(实时帧的 `data.id` / 落库帧的 `message.id`)——两条路**唯一的公共键**,
|
||||
* 也是服务端唯一保证「同一条消息同一个值」的键;
|
||||
* 2. IM 侧消息号(只有实时帧有:落库失败那一档它仍然在,`id` 缺席);
|
||||
* 3. 时间字面量(同上,最后的服务端键);
|
||||
* 4. [fallback](调用方传**正文**)——服务端一个键都没给时的兜底。
|
||||
*
|
||||
* 第 4 条是刻意的取舍:它会让「没有 id、正文又一个字不差」的两条消息被并成一条(少弹一次),
|
||||
* 而反过来(不兜底 ⇒ 无键可查 ⇒ 每来一帧都弹)会把服务端重推的同一条话弹成两条。**少弹**
|
||||
* 是这两边更轻的那一侧——通知只是提醒,话本身在聊天页里一条都不少。
|
||||
*/
|
||||
fun proactiveNotifyKey(
|
||||
id: String?,
|
||||
messageId: String?,
|
||||
timestamp: String?,
|
||||
fallback: String,
|
||||
): String = listOf(id, messageId, timestamp)
|
||||
.firstOrNull { !it.isNullOrBlank() }
|
||||
?: fallback
|
||||
|
||||
/**
|
||||
* 派生 id 的取值**下界**(见 [proactiveNotificationId])。
|
||||
*
|
||||
* 通知 id 与 `PendingIntent` 的 request code 都是**全 App 共用**的命名空间,而它们现有的占用者
|
||||
* (保活那条常驻通知 `KEEP_ALIVE_NOTIFICATION_ID` = 1、它点击用的 request code = 0)全在低位。
|
||||
* 从远高于它们的地方起步,「派生 id 不撞既有 id」就是**结构性**成立的,不是靠「碰巧没撞上」。
|
||||
*/
|
||||
private const val PROACTIVE_NOTIFICATION_ID_FLOOR = 1_000
|
||||
|
||||
/**
|
||||
* 派生 id 的取值**宽度**(见 [proactiveNotificationId])。
|
||||
*
|
||||
* 取这么宽是为了让 id 空间尽量大(撞号的概率随宽度下降),同时**贴着 `Int.MAX_VALUE` 留出下界的
|
||||
* 位置**:「下界 + 宽度」一旦越过 `Int.MAX_VALUE`,末尾的 `.toInt()` 就会折回负数。
|
||||
*/
|
||||
private const val PROACTIVE_NOTIFICATION_ID_SPAN = 2_000_000_000
|
||||
|
||||
/**
|
||||
* 一条 proactive 消息对应的**通知 id(同时也是它点击用的 request code)**——纯函数,由去重键
|
||||
* [proactiveNotifyKey] 的输出派生。这就是「一事一条」的全部落地形状(W54A)。
|
||||
*
|
||||
* ## 为什么必须与去重键同源
|
||||
* 改前所有她的话共用一个固定 id,而 `notify` 遇同 id 是**替换**:她连说几句,用户只看到最后
|
||||
* 一句,前面那几句在他拿起手机之前就已经没了。按消息派生要回答的第一个问题是「凭什么是同一条」
|
||||
* ——那正是 [proactiveNotifyKey] 已经回答过的(实时帧与落库帧共用它)。**两处口径必须永远同源**:
|
||||
* 去重说「这是同一条」、派生说「这是同一条 ⇒ 同一个号」;分成两套,就会出现「认得出是同一条、
|
||||
* 却排出两条 id」的形状漂移。
|
||||
*
|
||||
* ## 它会撞号,后果如实说
|
||||
* `String.hashCode()` 只有 32 位,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 这一段 ⇒ **不同消息
|
||||
* 派生出同一个 id 是会发生的**(约 2×10⁹ 的空间,按生日问题算,通知栏里装几十条时概率可忽略)。
|
||||
* 撞上的后果与改前**一模一样**:后来那条替换先来那条,用户少看见一条提醒。这是刻意接受的那一侧
|
||||
* ——通知只是「她找你了」这一下,她的话本身在主会话里一条不少;反过来要绝对不撞,就得在服务端
|
||||
* 唯一号之外再维护一套映射并持久化它,代价比「极少数情况下少一条提醒」大得多。
|
||||
*
|
||||
* ## 两个坑都在这里堵上
|
||||
* 1. **号必须为正**:通知 id 是一个有符号 `int`,而这个命名空间里正数是纪律(本仓保活那条常驻通知
|
||||
* 是 1;平台对前台服务的 id 更直接写明「must not be 0」)。而 `hashCode()` 可以是负的、甚至
|
||||
* 正好是 `Int.MIN_VALUE`——后者连 `abs` 都兜不住(在 int 域里 `abs(Int.MIN_VALUE)` 仍是它自己)。
|
||||
* 所以这里**先展宽到 `Long` 再取绝对值**:`Int.MIN_VALUE.toLong()` = 2147483648,取绝对值是安全
|
||||
* 的,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 后落在正区间。
|
||||
* 2. **不撞既有 id**:见 [PROACTIVE_NOTIFICATION_ID_FLOOR]。
|
||||
*/
|
||||
internal fun proactiveNotificationId(key: String): Int {
|
||||
val magnitude = key.hashCode().toLong().absoluteValue
|
||||
return (PROACTIVE_NOTIFICATION_ID_FLOOR + magnitude % PROACTIVE_NOTIFICATION_ID_SPAN).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知正文:她的原话,**剥掉标记、折成一行、过长截断**;没有可显示的字时回 `null`。
|
||||
*
|
||||
* ## 为什么不直接发原始 `content`
|
||||
* 她的内容里带 `<p>` / `<action>` / `<narration>` 标记(服务端原样落库),直接放进通知会把标签
|
||||
* 露给用户(本仓在会话列表那一行上早就踩过同一件事,见 `ui.sessions.toPreviewText`)。
|
||||
* 这里**复用权威解析器** [parse] 而不是再写一份剥标签的正则:同一件事有两份口径早晚会分叉,
|
||||
* 而分叉的那一侧就是通知里露标签的那一侧。解析出的全部片段按原顺序拼回(旁白/动作也留着——
|
||||
* 那也是她说的话),片段之间补空格,免得「正文紧贴旁白」时两侧的字粘成一个词。
|
||||
*
|
||||
* 截断按 UTF-16 码元走,但**不劈开代理对**(emoji 这类字符由两个码元组成,劈开会显示成半个
|
||||
* 乱码方块):末尾正好落在高代理项上时整体后退一位。
|
||||
*/
|
||||
fun proactiveNotificationText(
|
||||
content: String,
|
||||
maxChars: Int = PROACTIVE_NOTIFICATION_MAX_CHARS,
|
||||
): String? {
|
||||
val flat = parse(content)
|
||||
.joinToString(" ") { bubble -> bubble.parts.joinToString(" ") { part -> part.text } }
|
||||
.replace(WHITESPACE_RE, " ")
|
||||
.trim()
|
||||
if (flat.isEmpty()) return null
|
||||
// maxChars <= 0 = 「不截断」(调用方明确要全文);其余情况只在真的超长时才动手。
|
||||
if (maxChars <= 0 || flat.length <= maxChars) return flat
|
||||
|
||||
var end = maxChars
|
||||
if (Character.isHighSurrogate(flat[end - 1])) end -= 1
|
||||
return flat.substring(0, end).trimEnd() + "…"
|
||||
}
|
||||
|
||||
private val WHITESPACE_RE = Regex("\\s+")
|
||||
@@ -8,36 +8,93 @@ 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")
|
||||
|
||||
enum class ThemeMode { System, Light, Dark }
|
||||
|
||||
/**
|
||||
* 她正忙时按发送的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`)。
|
||||
* 她忙时发消息的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`;忙时并行规范 §2.2 的
|
||||
* `chat.parallel`)。
|
||||
*
|
||||
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **整键省略**
|
||||
* (服务端视同 `"all"`,与加入 `scope` 之前那条帧逐字节相同)。
|
||||
* 类型名与落盘键(`interrupt_mode`)都是**历史名字**:第三档「并行」接上来之后它管的不再只是打断,
|
||||
* 但改类型名 / 键名 / 既有档位的 `name` 都会让已存偏好读不出来并**静默回落**默认档——所以三个名字
|
||||
* 一个都不动(见 [toInterruptMode] 与 `InterruptModeTest` 的「落盘名稳定」那几条)。
|
||||
*
|
||||
* 一档同时表达**两个上行声明**:[wireScope] 与 [wireParallel]。三档各占一种组合——打断占前者、
|
||||
* 并行占后者、追加队列两个都不占,**没有任何一档同时给两个**。
|
||||
*
|
||||
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **不发打断帧**。注意它不是
|
||||
* 「发一条省略 scope 的帧」——那在服务端是 `"all"`(连待处理队列一起清),是完全不同的语义。
|
||||
* @property wireParallel 这条 `chat` 要不要声明 `parallel:true`;`false` = **一个字节都不带**。
|
||||
*/
|
||||
enum class InterruptMode(val wireScope: String?) {
|
||||
enum class InterruptMode(val wireScope: String?, val wireParallel: Boolean) {
|
||||
|
||||
/**
|
||||
* 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。
|
||||
* 追加队列(默认):忙时只把消息排进队列,不打断也不并行。
|
||||
*
|
||||
* 该模式**不发** `interrupt`;一旦因别的触发点要发,就用省略 `scope` 的旧形状
|
||||
* (服务端 = `all`:取消在跑回合 + 清空待处理队列)——这是与旧版逐字节兼容的那一档。
|
||||
* 该模式**两个声明都不发**(`interruptScopeForSend` 恒为「不发」、`parallelForSend` 恒为
|
||||
* `false`):默认档的线上请求与本偏好存在之前**逐字节相同**。
|
||||
*/
|
||||
AppendQueue(null),
|
||||
AppendQueue(wireScope = null, wireParallel = false),
|
||||
|
||||
/**
|
||||
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
|
||||
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
|
||||
*/
|
||||
Interrupt("current"),
|
||||
Interrupt(wireScope = "current", wireParallel = false),
|
||||
|
||||
/**
|
||||
* 并行:忙时**不打断**在跑的那一路,本条**另起一路**同时处理(`chat.parallel=true`);
|
||||
* 服务端并发额度满时**不报错**,这一条落回排队。
|
||||
*
|
||||
* [wireScope] 为 `null` 不是因为「这一档没什么要发的东西」(它当然要发东西,发的是
|
||||
* `chat.parallel`),而是因为**并行与打断互斥**:这一档**绝不允许**产生 `interrupt` 帧——
|
||||
* 一产生就等于先把要并行的那一路砍掉。
|
||||
*
|
||||
* 代价:两路各算一遍 ⇒ 峰值算力 / token 近翻倍(主人 2026-10-05 裁决 4:不分摊、优先性能),
|
||||
* 所以设置页这一栏的 summary 把这句代价明说出来。
|
||||
*/
|
||||
Parallel(wireScope = null, wireParallel = true),
|
||||
}
|
||||
|
||||
/**
|
||||
* 落盘字符串 → 档位(`AppSettings` 的 `interrupt_mode` 键读取口;抽成顶层函数是为了能单测——
|
||||
* `AppSettings` 本体要 Android + DataStore,单测里起不来)。
|
||||
*
|
||||
* 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。
|
||||
*/
|
||||
fun String?.toInterruptMode(): InterruptMode = when (this) {
|
||||
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
|
||||
InterruptMode.Parallel.name -> InterruptMode.Parallel
|
||||
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 级偏好(非机密)。
|
||||
*
|
||||
@@ -45,12 +102,20 @@ enum class InterruptMode(val wireScope: String?) {
|
||||
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
|
||||
* @param thinkingExpanded 思维链卡片默认是否展开
|
||||
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
|
||||
* @param keepAliveEnabled 后台保持连接(常驻一条前台服务,**默认关**;判据见
|
||||
* `core.keepalive.shouldKeepAliveRun`)
|
||||
* @param proactiveQuiet 免打扰:她主动找你说话**只在通知栏提示,不在屏幕顶部弹横幅**(**默认
|
||||
* `false` = 都弹**)。⚠️ 它的语义**不是静音**:提示音与通知栏条目照旧,只是不盖住用户正在做的
|
||||
* 事。落地形状是**换一条渠道**(见 `core.notify.proactiveChannelId`)——横幅由渠道重要级决定,
|
||||
* 而重要级建完就改不了,所以只能两条渠道切着用
|
||||
*/
|
||||
data class AppPrefs(
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
val showNarration: Boolean = true,
|
||||
val thinkingExpanded: Boolean = true,
|
||||
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
|
||||
val keepAliveEnabled: Boolean = false,
|
||||
val proactiveQuiet: Boolean = false,
|
||||
)
|
||||
|
||||
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
|
||||
@@ -60,15 +125,45 @@ 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(),
|
||||
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
|
||||
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 }
|
||||
}
|
||||
@@ -85,22 +180,31 @@ class AppSettings(context: Context) {
|
||||
store.edit { it[KEY_INTERRUPT_MODE] = mode.name }
|
||||
}
|
||||
|
||||
suspend fun setKeepAliveEnabled(enabled: Boolean) {
|
||||
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
|
||||
else -> ThemeMode.System
|
||||
}
|
||||
|
||||
/** 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。 */
|
||||
private fun String?.toInterruptMode(): InterruptMode = when (this) {
|
||||
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
|
||||
else -> InterruptMode.AppendQueue
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataS
|
||||
*
|
||||
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
|
||||
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
|
||||
* @param fingerprint `baseUrl + username`;换服务器/账号后快照作废
|
||||
* @param fingerprint [accountFingerprint]:换服务器/账号后快照作废
|
||||
* @param activeSessionId 上次停留的会话
|
||||
*/
|
||||
data class SessionPrefs(
|
||||
@@ -35,6 +35,20 @@ data class SessionPrefs(
|
||||
snapshot.takeIf { this.fingerprint == fingerprint }.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号指纹(纯函数):`baseUrl + username`。回答的是「**这份本地状态属于哪个环境**」。
|
||||
*
|
||||
* 抽成一个函数的理由是**全仓只能有一份口径**:这类判据一旦各拼一套字符串,差一个分隔符
|
||||
* 就会在某个闸门上漏过去——而漏过去的后果是把别的服务器/账号的内容摆到屏幕上。
|
||||
* 现在的两个消费者是 [SessionPrefs.snapshotFor](会话快照作不作废)与聊天页的 VM key
|
||||
* (`chatViewModelKey`:那个 VM 是不是本环境的)。改口径就是改**两者**,不要就地另拼。
|
||||
*
|
||||
* 取用户名而不是角色:角色是同一个人在服务端被授的权限,账号身份由**服务器地址 + 用户名**
|
||||
* 共同确定;要让指纹对不上,只能是换了服务器或换了账号,那两件事都该让本地状态作废。
|
||||
*/
|
||||
internal fun accountFingerprint(baseUrl: String, username: String?): String =
|
||||
"$baseUrl|${username.orEmpty()}"
|
||||
|
||||
class SessionPreferences(context: Context) {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package top.yeij.cyrene.core.session
|
||||
|
||||
/**
|
||||
* 本端**同时**为一个账号留几个会话槽位(N=3,[chatSessionEvictions] 的默认 `keep`)。
|
||||
*
|
||||
* 为什么是 3(**主人拍板的口径**):它要在「切回去时那几屏东西还在」与「不留着一堆没人再看的
|
||||
* 消息列表」之间取一个数。驱动这个数的是**内存**——每个槽位留着一整页已落库消息(次级会话里可能
|
||||
* 是内联 base64 图片),而 S4 登记的正是「访问过的会话一个都不回收」。不跟着会话数走的理由也在
|
||||
* 这里:它是内存上限,不是「用户可能有多少会话」。
|
||||
*/
|
||||
const val CHAT_SESSION_SLOT_KEEP = 3
|
||||
|
||||
/**
|
||||
* 一格槽位在**被判定那一刻**的样子([chatSessionEvictions] 的入参,**纯值**)。
|
||||
*
|
||||
* 三项都由调用方现读现喂,判据自己不看 VM、不读表、不碰时钟(先例:`ui.sessions.silentRefreshStep`
|
||||
* 只回结论,账本与时钟都由调用方喂)——所以它在 JVM 上可以穷举。
|
||||
*
|
||||
* @param key 槽位的稳定标识(`账号指纹|会话 id`)。它就是**结论的载体**:判据只把它原样列出来,
|
||||
* 「拿这个 key 去哪里找槽位」是调用方的事。
|
||||
* @param sessionId 会话 id。**它和 [key] 不是同一件事**:key 里还带着账号指纹(换账号之后同一个
|
||||
* 会话 id 是另一格),而这一位只回答一个问题——**它是不是主会话**(主会话永不回收,见
|
||||
* [chatSessionEvictions] 第 1 条)。两样都留着是刻意的:把「主会话」这件事换个说法搬出判据,
|
||||
* 它就再也钉不住了(那是本任务的红线之一)。
|
||||
* @param lastUsedAt **最后一次成为当前会话**的时刻(毫秒)。「最近」的口径就是它,理由见
|
||||
* [chatSessionEvictions]。`0` = 建出来还没打开过(组合被丢弃时留下的空槽位)。
|
||||
* @param holdsUnsentWork 这一格里还有**本端唯一一份**的内容(没发出去的草稿 / 没上路的发送)。
|
||||
* 它必须一路传到这里,是因为「回收」是一条**回不来**的动作:判据要在动手之前就能拦下
|
||||
* 「把用户还没发出去的字一起收掉」——那比「多留一格」严重得多(红线:不许静默丢内容)。
|
||||
*/
|
||||
internal data class ChatSessionUsage(
|
||||
val key: String,
|
||||
val sessionId: String,
|
||||
val lastUsedAt: Long,
|
||||
val holdsUnsentWork: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 这一刻该交出去哪几格(**纯函数**,[ChatSessionSlots] 与测试共用)。返回**要回收的槽位 key**,
|
||||
* 最该走的在最前(最旧的最先);没有要回收的就返回空表。
|
||||
*
|
||||
* ## 「最近」的口径:最后一次**成为当前会话**的时刻
|
||||
* 三选一里选的它,理由是另外两个都答不对这个问题:
|
||||
* - 「最后一次收到这个会话的帧」**不是用户的活动**:共享连接上别的会话的帧一直在来(服务端不按
|
||||
* 会话订阅),一条后台广播就能把一个用户根本没再打开过的会话顶成「最近」。
|
||||
* - 「最后一次有新消息」同理,而且是**别人**的活动(她在次级会话里说一句,本端就要为它留一格)。
|
||||
* - 「最后一次成为当前会话」才是「用户切回去时希望看到什么」这件事本身:槽位存在的意义就是
|
||||
* 那一屏不用重新加载,而**用户看得见哪个会话**只有这一个时刻能表达。
|
||||
*
|
||||
* 它由 `ChatScreen` 在**组合到某个会话时**写入(那正是「它成了当前会话」这一刻),所以判据这边
|
||||
* 只需要比大小,不需要任何时钟:谁的时刻新,谁就留下。同一毫秒并列时按**账本里的先后**退回
|
||||
* ([usages] 的顺序 = 槽位被建出来的顺序,见 [ChatSessionSlots]),也就是「谁先建谁先走」。
|
||||
*
|
||||
* ## 判据与优先级(自上而下,先命中先返回)
|
||||
* 1. **主会话永不回收**:它是抽屉与主流程的锚——用户随时会退回它,而它那一格正是「一进 App
|
||||
* 就有东西看」的来处。这一条**不看** `keep`,也不占「最近」的名额:主会话就是主会话,
|
||||
* 不看它多久没被打开过。
|
||||
* 2. **屏幕上那一格永不回收**(`key == [currentKey]`):它的 VM 一死,屏幕上那一段就再也不会更新
|
||||
* (帧收不到、`viewModelScope` 已经取消 ⇒ 连发送都会静默不发),要等下一次重组才会自己修回来
|
||||
* —— 那是「回收」绝不能带来的一种状态。这一条与主会话同属「按身份豁免」,与时刻无关
|
||||
* ([currentKey] 那一格通常本来就是最新的,但不靠这件事成立)。
|
||||
* 3. **还有没交代完的事的槽位不回收**([ChatSessionUsage.holdsUnsentWork]):那些内容只在本端
|
||||
* 这一份(草稿、排队等连接的发送),回收掉就**回不来**了。这一条压过 `keep` 的语义——
|
||||
* 它宁可让本端多留一格,也不做一件不可逆的事。
|
||||
* 4. 其余按 [ChatSessionUsage.lastUsedAt] 从新到旧留 `keep` 格,**连上面三条占住的一起算**:
|
||||
* `keep` 是「本端同时保留几格」这个内存目标,所以被豁免的那些**占名额**——不然「最多 3 格」
|
||||
* 在豁免生效时会变成 3 + 豁免数,那个数就没有上限了。(真实使用里豁免的那些几乎总是
|
||||
* 「刚刚还在用的」,本来就在最近 3 个里,这条只在极端情况下才真的挤掉谁。)
|
||||
*
|
||||
* 判据里除了这些入参什么都不看,才能穷举、才能在 JVM 上单测(先例:`ui.chat.reconnectPull`、
|
||||
* `ui.sessions.silentRefreshStep`)。
|
||||
*
|
||||
* ## ⚠️ 它**只**回答「交出去哪几格」
|
||||
* 「交出去」具体是什么动作(清掉那份 `ViewModelStore`、于是这一格的 VM 随 `onCleared` 一起走)
|
||||
* 是调用方的事,而且**必须**是那份 store 自己的 `clear()`:连接是全 App 共享的那一条,它的生死
|
||||
* 只由登录态表达(主人是 `SessionsViewModel`),回收一格**绝不能**碰它——回收一格的状态与
|
||||
* 「别人的流、别人的未读」是两件事,这条判据里连一个字节的连接概念都没有。
|
||||
*
|
||||
* @param usages 账本里的全部槽位(顺序有意义,见「同一毫秒并列」那一句)。
|
||||
* @param mainSessionId 主会话的 id(由装配处注入,判据不认识那个常量本身)。
|
||||
* @param currentKey **此刻屏幕上**那一格的 key([ChatSessionSlots] 刚记完账的那一格)。
|
||||
* @param keep 本端**同时**保留几格(含主会话、屏幕上那一格与「还有没交代完的事」的那些),
|
||||
* 默认 [CHAT_SESSION_SLOT_KEEP]。
|
||||
*/
|
||||
internal fun chatSessionEvictions(
|
||||
usages: List<ChatSessionUsage>,
|
||||
mainSessionId: String,
|
||||
currentKey: String,
|
||||
keep: Int = CHAT_SESSION_SLOT_KEEP,
|
||||
): List<String> {
|
||||
val reclaimable = usages
|
||||
.filter {
|
||||
it.sessionId != mainSessionId && it.key != currentKey && !it.holdsUnsentWork
|
||||
}
|
||||
.sortedBy { it.lastUsedAt }
|
||||
val budget = keep - (usages.size - reclaimable.size)
|
||||
val surplus = reclaimable.size - budget
|
||||
if (surplus <= 0) return emptyList()
|
||||
return reclaimable.take(surplus).map { it.key }
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package top.yeij.cyrene.core.session
|
||||
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
|
||||
/**
|
||||
* 本端留着的**会话槽位**(`账号指纹|会话 id` → 那一格的聊天状态)。S4 的解药。
|
||||
*
|
||||
* ## 它回答的是哪一个问题
|
||||
* `ChatViewModel` 是**按会话**建的(`sessionId` 是构造参数),于是「切走 / 删会话都不回收」意味着
|
||||
* 访问过 N 个会话就常驻 N 份消息列表(含内联 base64 图片)、N 份对共享连接的订阅、以及**每次重连
|
||||
* 各自一轮** `after` 补拉。这里就是那个「N」的闸门:**一格一个 `ViewModelStore`**,超出的按
|
||||
* [chatSessionEvictions] 的结论交出整格。
|
||||
*
|
||||
* ## 为什么一格一个 `ViewModelStore`(而不是给 key 加一代号)
|
||||
* 回收的本体动作是「让那一格的 VM 真正死掉」:`ViewModelStore.clear()` 会走 `ViewModel.onCleared()`
|
||||
* 并取消 `viewModelScope`——于是**订阅、补拉、正在飞的那些请求一起停下**。此前每一格都塞在
|
||||
* Activity 级那个 store 里,而它**只有整体 `clear()`**,没有「按 key 交出一格」的入口:那种情况下
|
||||
* 只能让 key 换一代、把旧实例**留在 store 里**(它照样活着、照样收帧——那正是 S4 的另一半没修掉
|
||||
* 的样子)。一格一份 store 之后,`clear()` 的粒度就正好是「这一格」。
|
||||
*
|
||||
* ## 回收**不碰**共享连接
|
||||
* 连接是全 App 唯一的那一条(`AppContainer.chatSocket`),它的生死只由登录态表达
|
||||
* (主人是 `SessionsViewModel`)。本类碰的只有「自己那一格的 store」:掐掉的是这一格的订阅,
|
||||
* 别的会话的流、别的会话的未读、以及那条连接本身一个字节都不动。
|
||||
*
|
||||
* ## 谁碰它、什么时候(全部在主线程上)
|
||||
* - **`ChatScreen`**:组合到某个会话时 [slotFor] 取(或建)槽位——VM 必须有地方建,而槽位就是
|
||||
* 那一格的存放处,所以这一步没法挪到组合之后;组合完成之后 [onOpened] 记账 + 回收。
|
||||
* - **`SessionsViewModel`**:本端**唯一**的删除入口,删成功后 [reclaim](那一格跟着会话一起走);
|
||||
* 登出 / 换账号时 [clear](与快照 / 已读游标 / [GoneSessions] 同一档:换代之后本端什么都不知道)。
|
||||
* - 三者都在主线程上(组合、`viewModelScope` 的默认派发器),所以账本本身不加锁——它的每一个
|
||||
* 读写点都在同一个线程上。
|
||||
*
|
||||
* ## 为什么不持久化
|
||||
* 槽位里装的是**进程内存里的状态**(消息列表、草稿、流式缓冲),它没有可以落盘的形式,而它的
|
||||
* 内容全部另有来源(服务端的 `GET /messages` 那一页 + 服务端的幂等键)。重启之后本端重新拉,
|
||||
* 与 [GoneSessions] 「不持久化」是同一个理由。
|
||||
*/
|
||||
class ChatSessionSlots(private val mainSessionId: String) {
|
||||
|
||||
/**
|
||||
* 账本。用 `LinkedHashMap` 只为一件事:**同一毫秒并列**时 [chatSessionEvictions] 的稳定排序会
|
||||
* 退回这个插入顺序(先建出来的先走)。除此之外顺序不表达任何东西——「谁最近」一律看
|
||||
* [ChatSessionSlot] 里的 `lastUsedAt`。
|
||||
*/
|
||||
private val slots = linkedMapOf<String, ChatSessionSlot>()
|
||||
|
||||
/**
|
||||
* 取这个会话的槽位(没有就建一个)。**组合期间**调用。
|
||||
*
|
||||
* 它**不记账**:谁最近用过由 [onOpened] 写。所以就算这一段组合被丢弃(组合没走完),留下的也
|
||||
* 只是一个 `lastUsedAt` 还是 `0` 的空槽位,下一次回收就会把它扫掉(它必然是最旧的那一格)。
|
||||
*/
|
||||
fun slotFor(account: String, sessionId: String): ChatSessionSlot =
|
||||
slots.getOrPut(keyOf(account, sessionId)) { ChatSessionSlot(sessionId) }
|
||||
|
||||
/**
|
||||
* 这个会话**刚刚成为当前会话**:记下它是「最近」的那一格,然后按 [chatSessionEvictions] 回收。
|
||||
*
|
||||
* 记账与回收在同一处,是因为回收的判据就是这份账本本身(先例:`ui.sessions.silentRefreshStep`
|
||||
* 把「该做什么」与「账本走成什么样」一起回):拆开就会给出「判了却忘了推进账本」的机会,
|
||||
* 而那正好会让最该留下的那一格被当成最旧的收掉。同一处也才拿得到那个 key —— 判据要
|
||||
* **按身份**豁免的就是刚记完账这一格(`chatSessionEvictions` 的 `currentKey`)。
|
||||
*
|
||||
* @param holdsUnsentWork 现读的一格真相(见 [ChatSessionUsage.holdsUnsentWork])。收的是一个
|
||||
* **探针**而不是一个布尔快照,是因为回收发生在**别的**会话被打开的那一刻 —— 而那一刻正是
|
||||
* 用户可能刚在前一个会话里打了草稿、还没切走的时候。快照会把它读成「干净」,把没发出去的
|
||||
* 字一起回收掉(那是一条回不来的动作)。
|
||||
*/
|
||||
fun onOpened(
|
||||
account: String,
|
||||
sessionId: String,
|
||||
holdsUnsentWork: () -> Boolean,
|
||||
nowMillis: Long,
|
||||
) {
|
||||
val key = keyOf(account, sessionId)
|
||||
slotFor(account, sessionId).opened(holdsUnsentWork, nowMillis)
|
||||
sweep(currentKey = key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 定点回收一格(删会话):把那份 store 交出去,那个 VM 随 `onCleared` 一起走,本类不再引用它。
|
||||
*
|
||||
* 与 [GoneSessions] 的关系:那是「本端已知它没了」这件事的**第一半**(让还活着的 VM 别再去发
|
||||
* 注定 404 的补拉),这里是**第二半**(那一格本来就该跟着会话一起消失)。两半分开写在两处,
|
||||
* 是因为它们回答的问题不同——一个答「还值不值得发请求」,一个答「还留不留这一格状态」,
|
||||
* 而后者要动 VM 的生命周期,不该被前者拽住(`ui.chat.reconnectPull` 只读前者)。
|
||||
*/
|
||||
fun reclaim(account: String, sessionId: String) {
|
||||
slots.remove(keyOf(account, sessionId))?.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 换账号 / 登出:本端**一格都不留**。
|
||||
*
|
||||
* 与快照 / 已读游标 / [GoneSessions] 是同一档纪律(`SessionsViewModel` 的登出分支一处清完):
|
||||
* 槽位里的东西全部属于**上一个环境**,留着只会在下一次登录后变成一份没人认领的常驻内存
|
||||
* (而它带着的那个 VM 还在订阅连接、还会在重连时补拉)。这一条也顺带把 [ChatSessionSlot] 的
|
||||
* 那条「主会话永不回收」的例外收干净:换账号时连主会话那一格也不留。
|
||||
*
|
||||
* 代价说清楚:登出(含令牌失效那条路径)之后**本端未发出的草稿会跟着没了**。这与既有口径一致
|
||||
* ——登出时快照、已读游标、已删会话的结论都是当场清掉的(那些也都没落盘)。
|
||||
*/
|
||||
fun clear() {
|
||||
slots.values.forEach { it.clear() }
|
||||
slots.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 [chatSessionEvictions] 的结论交出超出的那几格(「最近」的口径见那个函数的 KDoc)。
|
||||
*
|
||||
* @param currentKey 刚记完账、此刻在屏幕上那一格的 key(它按身份豁免,见判据第 2 条)。
|
||||
*/
|
||||
private fun sweep(currentKey: String) {
|
||||
val usages = slots.map { (key, slot) -> slot.usage(key) }
|
||||
chatSessionEvictions(usages, mainSessionId, currentKey)
|
||||
.forEach { key -> slots.remove(key)?.clear() }
|
||||
}
|
||||
|
||||
/** 与聊天页的 VM key 无关:这是**槽位**的身份,账号指纹在里面是为了换账号时天然分格。 */
|
||||
private fun keyOf(account: String, sessionId: String): String = "$account|$sessionId"
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个会话的槽位:**它自己的那份 `ViewModelStore`**,外加这项回收策略要的两个记账位。
|
||||
*
|
||||
* 装在这里的 VM 由 `ChatScreen` 经它取出(`viewModel(viewModelStoreOwner = 槽位, key = <账号指纹|会话 id>)`):
|
||||
* key 仍然是 `chatViewModelKey` 那一份口径,只是现在**配着一份按会话分的 store** 用。
|
||||
*
|
||||
* 它**不持有** VM 的类型:本类只知道「怎么把这一格交出去」([clear])与「它还有没有没交代完的事」
|
||||
* ([opened] 收下的那个探针)。这样 `core.session` 不必认识 `ui.chat` 的任何类型,而两件事
|
||||
* ——「槽位怎么算最近」与「VM 里哪一位算没交代完」——各自待在自己那一层。
|
||||
*/
|
||||
class ChatSessionSlot internal constructor(
|
||||
/** 本槽位的会话 id(槽位身份的一半:账号指纹在 [ChatSessionSlots] 的账本 key 里)。 */
|
||||
val sessionId: String,
|
||||
) : ViewModelStoreOwner {
|
||||
|
||||
private val store = ViewModelStore()
|
||||
|
||||
override val viewModelStore: ViewModelStore
|
||||
get() = store
|
||||
|
||||
/**
|
||||
* 最后一次成为当前会话的时刻(毫秒);`0` = 建出来还没打开过。**只增不改**。
|
||||
*
|
||||
* 由 `ChatSessionSlots.onOpened` 写(那里是「它成了当前会话」的唯一表达处),本类不自己读时钟。
|
||||
* 不进 [usage] 以外的任何地方——「最近」的判据只认它,而判据只吃 [ChatSessionUsage]。
|
||||
*/
|
||||
private var lastUsedAt: Long = 0L
|
||||
|
||||
/**
|
||||
* 这一格里「还有没有没交代完的事」的**现读**探针(见 [ChatSessionSlot.opened] 的说明)。
|
||||
* 默认恒 `false`:还没打开过的一格当然没有。
|
||||
*/
|
||||
private var holdsUnsentWork: () -> Boolean = { false }
|
||||
|
||||
/** 这一格成了当前会话:记下该问谁「还有没有没交代完的事」,并更新「最近」这一位。 */
|
||||
internal fun opened(holdsUnsentWork: () -> Boolean, nowMillis: Long) {
|
||||
this.holdsUnsentWork = holdsUnsentWork
|
||||
lastUsedAt = nowMillis
|
||||
}
|
||||
|
||||
/** 交给判据的样子(判据不认识本类,见 [ChatSessionUsage])。 */
|
||||
internal fun usage(key: String): ChatSessionUsage = ChatSessionUsage(
|
||||
key = key,
|
||||
sessionId = sessionId,
|
||||
lastUsedAt = lastUsedAt,
|
||||
holdsUnsentWork = holdsUnsentWork(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 交出这一格:`ViewModelStore.clear()` ⇒ 那一格的 `ViewModel.onCleared()` + `viewModelScope`
|
||||
* 取消(订阅、补拉、在飞的请求一起停),随后**松开探针**。
|
||||
*
|
||||
* 松开探针不能省:它捕获着那个 VM(否则就成了一份「已经交出去、却还被人拎着」的引用,
|
||||
* 那一格的消息列表会跟着这条引用活到进程结束——正是本类要断掉的那根线)。
|
||||
*/
|
||||
internal fun clear() {
|
||||
store.clear()
|
||||
holdsUnsentWork = { false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package top.yeij.cyrene.core.session
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* 本端**已经知道服务端没有这个会话**的那几个会话 id(进程级、只在内存里、只增不减)。
|
||||
*
|
||||
* ## 它回答的是哪一个问题
|
||||
* 「这个 id 还值不值得我发一次请求」。`ChatViewModel` 是按会话建的,本端**同时**留着其中几格
|
||||
* (W52A 起是 `ChatSessionSlots` 的 N 格),它们都订阅同一条共享连接 ⇒ **每次重连各自会去补拉
|
||||
* 一轮**。会话在本端被删掉之后,那个 VM 自己并不知道(它没有别的渠道知道),于是**每一个**重连
|
||||
* 都发一次注定 `404 session_not_found` 的补拉,404 被静默吞掉(S7 登记的那一条)。
|
||||
*
|
||||
* 补拉**事前**就能判掉它,靠的就是这里:删除是**本端自己做的**,那一刻就已经知道结果了,
|
||||
* 没有任何理由等下一条请求回来才「发现」。于是这一位把「已知不存在」从「一次注定失败的往返」
|
||||
* 提前成「一次本地查询」——**不是**「重连时少拉一点」的省流量优化,而是不让一个注定失败的请求
|
||||
* 上路(它会占一条连接、写一行日志、并把 `sessionGone` 变成一个只能靠 404 才置得起来的位)。
|
||||
*
|
||||
* W52A 又给这件事加了一半:删会话时那一格**当场就被回收**(`ChatSessionSlots.reclaim`),所以
|
||||
* 大多数情况下这里拦的已经是一个不会再发生的补拉。它仍然留着,因为两件事回答的不是一个问题
|
||||
* ——这一位答「还值不值得发请求」,槽位答「还留不留这一格状态」,而**本端删掉一个会话**与
|
||||
* **那个会话在别处被删掉**(只能靠 404 发现)是两条不同的路,后者压根不经过这里。
|
||||
*
|
||||
* ## 为什么是进程级、为什么**不**持久化
|
||||
* - 进程级(挂在 `AppContainer` 上,与那条共享连接同一层):写它的是 `SessionsViewModel`
|
||||
* (本端唯一的删除入口),读它的是**别的** VM —— 两个 VM 之间没有别的共享面。
|
||||
* - **不持久化**:它只是「这一趟进程里已经知道的事」。重启之后本端的会话列表会重新从服务端拉
|
||||
* ([top.yeij.cyrene.core.prefs.SessionPreferences] 那份快照本来就会整体替换),「这个会话还在不在」
|
||||
* 以那一次服务端答复为准;把 id 落盘只会让一份陈旧记忆挡掉重启后的第一次拉取。
|
||||
* - **换账号要清**([clear]):id 是服务端铸的,理论上不会跨账号撞车,但这一位说的是「本端
|
||||
* 已经知道」,而换账号之后本端**什么都不知道** —— 留着上个环境的结论,是那种「差一个分隔符
|
||||
* 就漏过去」的同一类错误(先例:`accountFingerprint` 的注释)。同一处也清聊天页的槽位
|
||||
* (`ChatSessionSlots.clear`),两件事属同一档纪律。
|
||||
*/
|
||||
class GoneSessions {
|
||||
|
||||
private val ids = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** 记下「服务端已经没有这个会话了」。空串当无效值丢掉(那不是任何会话的 id)。 */
|
||||
fun mark(sessionId: String) {
|
||||
if (sessionId.isBlank()) return
|
||||
ids.update { it + sessionId }
|
||||
}
|
||||
|
||||
/** 本端是否**已经知道**它没了。只回答「知道不知道」,不回答「在不在」——不知道的一律 `false`。 */
|
||||
fun contains(sessionId: String): Boolean = sessionId in ids.value
|
||||
|
||||
/** 换账号 / 登出:本端重新回到「什么都不知道」。 */
|
||||
fun clear() {
|
||||
ids.value = emptySet()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
|
||||
@@ -25,6 +26,20 @@ interface AdminApi {
|
||||
@GET("api/v1/admin/observability/trace")
|
||||
suspend fun trace(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminTraceSpanDto>>>
|
||||
|
||||
/**
|
||||
* 后端日志 tail(api-client §2「可观测性契约」,v0.35):`{"data":[{time,level,source,msg}]}`,
|
||||
* **最新在前**;`limit` 缺省 200、上限 1000(≤0 或非法回落缺省)。
|
||||
*
|
||||
* 不暴露契约里的 `?level=`:它是**最低等级**过滤,只在服务端裁剪一次结果——而界面上那排
|
||||
* 等级档要能在同一份数据上来回切换(契约 §3.12 也明确推送帧的过滤只能本地做),
|
||||
* 所以这里一律取全量、由客户端本地过滤(`ui/admin/AdminOverviewMerge.kt`)。
|
||||
*
|
||||
* RBAC 是 v0.40 的 `viewerAuth`:**viewer** 也读得到(owner / admin 本来就可以),
|
||||
* 与 `trace` / `metrics` 同档。
|
||||
*/
|
||||
@GET("api/v1/admin/observability/logs")
|
||||
suspend fun logs(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminLogEntryDto>>>
|
||||
|
||||
/** 人格列表(含 admin 专属 `thinking`)。 */
|
||||
@GET("api/v1/admin/personas")
|
||||
suspend fun personas(): Response<ApiEnvelope<List<PersonaSummaryDto>>>
|
||||
|
||||
@@ -15,6 +15,32 @@ import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* **连接超时**(毫秒):解析 DNS + 建立 TCP 连接(以及 TLS 握手)这一段的预算,**全端共用这一个值**。
|
||||
*
|
||||
* ## 为什么从 10 秒缩到 2.5 秒(W45)
|
||||
* 它就是「**完全无网时快速进队列**」这条性质的唯一依据。W41 把这条性质寄托在一个 2.5 秒的探针上
|
||||
* (W41 那个探针 client 的 `callTimeout`),而那个探针**从不复用连接池**、每次都要现做一次冷握手
|
||||
* ——于是真机上出现了「探了,但请求一个字节都没写出去」,而它与「压根没探」在外部完全同形,推断
|
||||
* 不出是哪一档。W45 删掉探测、改成**直接试 REST** 之后,这条性质改由**连接超时**承担:连不上就在 2.5 秒
|
||||
* 内失败,消息进队列(内容一个字节都不丢,重发仍是同一个 `clientMsgId`)。
|
||||
*
|
||||
* 取值 2.5 秒:正常网络下一次 TCP 握手是毫秒到几十毫秒量级(局域网、ZeroTier / Tailscale 也远达
|
||||
* 不到秒级),2.5 秒是「人开始觉得卡」之前的量级,也与 W41 那 2.5 秒的探针预算**对齐**——换成直接
|
||||
* 试 REST 之后,「无网时用户最多等多久」因此没有变长。**绝不许**再回到 10 秒:那会让每一条离线
|
||||
* 消息的用户都白等十倍的时间,换来的只是「极端慢网下多一次成功的机会」。
|
||||
*
|
||||
* ## 它不管什么
|
||||
* 「连上了但服务端一直不回话」(半死的代理、接住连接却不回应)不是它的事——那是读 / 写超时
|
||||
* ([READ_TIMEOUT_SECONDS])与回落那一次投递自己的整通上限
|
||||
* ([top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])的事。两个闸各管一段,合起来才是
|
||||
* 「没网时用户最多等多久」。
|
||||
*/
|
||||
const val CONNECT_TIMEOUT_MILLIS = 2_500L
|
||||
|
||||
/** 读 / 写超时(秒):真请求(长文本、内联图片、文档字节)的耐心,与 [CONNECT_TIMEOUT_MILLIS] 分开。 */
|
||||
private const val READ_TIMEOUT_SECONDS = 60L
|
||||
|
||||
object ApiClientFactory {
|
||||
|
||||
val json: Json = Json {
|
||||
@@ -30,7 +56,20 @@ object ApiClientFactory {
|
||||
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
|
||||
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, unauthorizedPolicy)
|
||||
|
||||
/** 通用入口:按需构建任意 Retrofit service。 */
|
||||
/**
|
||||
* 通用入口:按需构建任意 Retrofit service。
|
||||
*
|
||||
* 超时**只有两个值**,都由文件顶上的常量解释:连接 [CONNECT_TIMEOUT_MILLIS](短,保证「完全
|
||||
* 无网时快速失败」)、读 / 写 [READ_TIMEOUT_SECONDS](长,真请求的耐心)。**刻意不设
|
||||
* `callTimeout`**:那会把文档字节下载与图片上传一并框住,而它们本来就是长请求;哪一次投递需要
|
||||
* 整通上限,就写在哪一次投递的调用点上(例:回落那一次的
|
||||
* [top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])。
|
||||
*
|
||||
* DEBUG 下挂 BASIC 日志(`logcat -s OkHttp`):`-->` 打在 `chain.proceed()` **之前**,所以
|
||||
* 「这条请求有没有发出去」与「死在连接还是死在回执」当场可分——回落那条路下一轮要靠它取证。
|
||||
* 不动 `Authorization`(BASIC 根本不打印任何头),级别**绝不许**再往上调:`/auth/refresh` 与
|
||||
* `/auth/logout` 把 refresh token 放在请求体里。
|
||||
*/
|
||||
fun <T : Any> create(
|
||||
baseUrl: String,
|
||||
service: Class<T>,
|
||||
@@ -38,9 +77,11 @@ object ApiClientFactory {
|
||||
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
|
||||
): T {
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
// 连接这一段刻意短(理由见 [CONNECT_TIMEOUT_MILLIS]):它是「无网时消息快速进队列」的
|
||||
// 唯一依据,所以它**不含**任何整通上限的意味。
|
||||
.connectTimeout(CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.addInterceptor(AuthInterceptor(tokenProvider, unauthorizedPolicy))
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Streaming
|
||||
import top.yeij.cyrene.data.api.dto.DocumentUploadResponse
|
||||
|
||||
/**
|
||||
* 文档存储端点(契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**,形状依据见 `02-前后端协同事项.md` 回执五 §一 S1)。
|
||||
* 权限 `owner|admin`。与图片链路([UploadApi] + `/api/v1/files/{name}`)**刻意分离**:
|
||||
*
|
||||
* | | 图片 | 文档 |
|
||||
* |---|---|---|
|
||||
* | 上传字段名 | `file` | `file`(同) |
|
||||
* | 上限 | 5 MiB | 20 MiB(`CHAT_DOCUMENT_MAX_BYTES` 默认,**与图片上限解耦**) |
|
||||
* | 读侧鉴权 | **不需要**(名字即凭据,供 `<img>`/provider 直接取) | **要 Bearer `owner|admin`** |
|
||||
* | 读侧响应 | 可内联渲染 | `Content-Disposition: attachment` + `nosniff`(**永不渲染**) |
|
||||
* | 成功载荷的 url | **绝对**同源地址,可直接当 `chat.image` | **相对**路径,客户端要的是 `name` |
|
||||
*
|
||||
* 为什么文档读侧要鉴权而图片不要:文档的摄取在**服务端**完成(正文进上下文),
|
||||
* 客户端不需要把一个「名字即凭据」的读侧打开;图片则必须被 `<img>` 与 provider 直接消费。
|
||||
*
|
||||
* ## 鉴权拦截器这一侧没问题的依据
|
||||
* Bearer 不是按接口挂的,而是**按 OkHttpClient** 挂的:[ApiClientFactory.create] 给每个
|
||||
* 客户端都装了 `AuthInterceptor`(`Authorization: Bearer <token>` + 401 单飞续期),
|
||||
* 因此只要 [DocumentRepository] 拿到的 `DocumentsApi` 是从 `ApiClientFactory.create` 建的
|
||||
* (`AppContainer` 里照 [UploadApi] 的写法),**两个端点都自带 Bearer**,不需要额外加头。
|
||||
*/
|
||||
interface DocumentsApi {
|
||||
|
||||
/**
|
||||
* 上传一份文档。
|
||||
*
|
||||
* @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`);**文件名必须带扩展名**,
|
||||
* 且该扩展名要落在服务端的 deny-by-default 白名单里——与图片不同,这里的名字
|
||||
* **真的参与裁决**(白名单扩展名 + 字节嗅探**合取**,两者都过才放行)。
|
||||
* 服务端随后丢弃该名字,按内容寻址重命名为 `<sha256[:32]><ext>`,所以名字里
|
||||
* 除了扩展名以外的内容不会被采用(也不会进路径)。
|
||||
*
|
||||
* 返回原始 [Response]:**`200` 不是错误**——同一份字节重复上传回 `200` + 同一个 `name` +
|
||||
* `deduped:true`,客户端必须当成功。状态码本身不承载额外语义(`413`/`415` 的区分在
|
||||
* `error.code` 里),所以仓库层可以直接走 [apiCall]。
|
||||
*/
|
||||
@Multipart
|
||||
@POST("api/v1/documents")
|
||||
suspend fun upload(@Part file: MultipartBody.Part): Response<ApiEnvelope<DocumentUploadResponse>>
|
||||
|
||||
/**
|
||||
* 取回一份文档的原始字节。
|
||||
*
|
||||
* 它**不是信封**(响应体就是文件本身),所以返回 [ResponseBody]:解码器只处理
|
||||
* `application/json`,这里必须自己读字节并在仓库层负责关闭。
|
||||
* [Streaming] 让 Retrofit 不把响应体一次性读进内存(单份最大 20 MiB,边界情况不小)。
|
||||
*
|
||||
* @param name 服务端存储名(`<32hex>.<ext>`)。形态非法 / 不存在服务端都回
|
||||
* `404 document_not_found`(不区分「非法」与「没有」),因此这里不做本地形态校验、
|
||||
* 也不拿它拼任何本地路径。
|
||||
*/
|
||||
@Streaming
|
||||
@GET("api/v1/documents/{name}")
|
||||
suspend fun download(@Path("name") name: String): Response<ResponseBody>
|
||||
}
|
||||
@@ -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,18 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.UserPersonaDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
|
||||
/**
|
||||
* **用户侧**角色目录(api-client §1 行 9)。任意已认证角色可读,无 token 才 `401`。
|
||||
*
|
||||
* 与 [AdminApi.personas] 分开是有意的:admin 端点每项多一个 `thinking`,而且整条路径要求
|
||||
* `viewer` 以上的管理档——拿它当用户侧接口用,普通用户身上会拿到 `403`。
|
||||
*/
|
||||
interface PersonaApi {
|
||||
|
||||
/** 角色列表;服务端顺序即权威顺序,客户端不重排。 */
|
||||
@GET("api/v1/personas")
|
||||
suspend fun personas(): Response<ApiEnvelope<List<UserPersonaDto>>>
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.DeleteMessageResponse
|
||||
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.UpdateMessageRequest
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
|
||||
import retrofit2.Response
|
||||
@@ -26,10 +29,26 @@ interface SessionsApi {
|
||||
@GET("api/v1/sessions")
|
||||
suspend fun listSessions(): Response<ApiEnvelope<List<SessionSummaryDto>>>
|
||||
|
||||
/** 无 body;服务端返回 `201 {data:{sessionId}}`。 */
|
||||
/**
|
||||
* 新建次级会话,**完全不带 body**(= 不绑角色,跟随全局默认角色)。
|
||||
*
|
||||
* 这个无参重载是给「不选角色」这条路留的:`personaId` 为 null 时走它,线上请求与本端支持
|
||||
* 选角之前**逐字节相同**——不会因为多了一个可选字段而改变任何既有行为。
|
||||
* 服务端返回 `201 {data:{sessionId}}`。
|
||||
*/
|
||||
@POST("api/v1/sessions")
|
||||
suspend fun createSession(): Response<ApiEnvelope<CreateSessionResponse>>
|
||||
|
||||
/**
|
||||
* 新建会话并**在创建时绑定角色**(v0.42)。
|
||||
*
|
||||
* `personaId` 为**未知角色** → `400 bad_request`;非空时响应会回显它。
|
||||
* 会话角色**只在创建时写入、之后不可变**:`PATCH /sessions/{id}` 收到 `personaId` 一律 `400`,
|
||||
* 要换角色只能新开会话(见 [UpdateSessionRequest])。
|
||||
*/
|
||||
@POST("api/v1/sessions")
|
||||
suspend fun createSession(@Body body: CreateSessionRequest): Response<ApiEnvelope<CreateSessionResponse>>
|
||||
|
||||
/** 主会话 `web` 删除会返回 `403 forbidden`。 */
|
||||
@DELETE("api/v1/sessions/{id}")
|
||||
suspend fun deleteSession(@Path("id") sessionId: String): Response<ApiEnvelope<DeleteSessionResponse>>
|
||||
@@ -51,9 +70,25 @@ interface SessionsApi {
|
||||
|
||||
/**
|
||||
* 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。
|
||||
*
|
||||
* 普通分页与**会话内内容搜索**共用这一个端点(契约 §1 行 6:`q` 是 messages 端点上的一个
|
||||
* 可选查询参数,不是另一个端点)。
|
||||
*
|
||||
* @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制)
|
||||
* @param before 上一页最旧消息的 `createdAt`(Unix 毫秒);null = 最新一页
|
||||
* @param platform 来源平台过滤;null = 全量
|
||||
* @param query `q`:**会话内内容搜索**(D2)。null / 空 = 未传,走普通分页——线上请求因此与本端
|
||||
* 支持搜索之前**逐字节相同**(Retrofit 会整个省掉 null 的查询参数)。非空时服务端按
|
||||
* 大小写不敏感的 ASCII LIKE 匹配 `content`(`%` / `_` / `\` 已转义为字面量),
|
||||
* 仍按时间倒序、`before` / `limit` 同分页语义,搜索**严格限定在 `{id}` 会话内**。
|
||||
* **`q` 与 `after` 互斥**(同传 `400`)。
|
||||
* @param after **正向增量**(A5,契约 §1 行 6):返回 `created_at > after` 的消息,**按时间
|
||||
* **升序**(与 `before` 的倒序**相反**)——用来补「WS 重连只回放最近 50 条」漏掉的那一段。
|
||||
* 游标是**本端已经看到的最后一条 `createdAt`**;服务端配 `limit` 从最旧的一条开始给,所以
|
||||
* 用「本页最后一条的 `createdAt`」反复推进就能一直追到当前。
|
||||
* **与 `before` / `q` 互斥**(同传 `400`)——三个调用点各自只给一个
|
||||
* (`loadPage` 只给 `before`、`search` 只给 `q`、`loadAfter` 只给 `after`),天然不冲突。
|
||||
* 同毫秒并列超过 `limit` 时纯 `createdAt` 游标会跨页重读,调用方按 `id` 去重兜底。
|
||||
*/
|
||||
@GET("api/v1/sessions/{id}/messages")
|
||||
suspend fun listMessages(
|
||||
@@ -61,6 +96,8 @@ interface SessionsApi {
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("before") before: Long? = null,
|
||||
@Query("platform") platform: String? = null,
|
||||
@Query("q") query: String? = null,
|
||||
@Query("after") after: Long? = null,
|
||||
): Response<ApiEnvelope<List<HistoryMessageDto>>>
|
||||
|
||||
/**
|
||||
@@ -75,4 +112,36 @@ interface SessionsApi {
|
||||
@Path("id") sessionId: String,
|
||||
@Body body: ReadSessionRequest,
|
||||
): Response<ApiEnvelope<ReadSessionResponse>>
|
||||
|
||||
/**
|
||||
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,body `{content}`。
|
||||
*
|
||||
* 响应是 `{data:<消息DTO>}`——`data` **直接就是**更新后的完整消息 DTO,**没有** `message`
|
||||
* 包一层(v0.30 变更记录里那句 `{data:{message}}` 已作废,以契约正文为准)。服务端只改
|
||||
* `content`,其余字段一律原值返回。
|
||||
*
|
||||
* 失败面:`content` 空 / 纯空白 / 超 4000 rune → `400 bad_request`;消息不存在 →
|
||||
* `404 not_found`;未注入 HistoryStore → `501`;权限不足 → `403`。
|
||||
*
|
||||
* ⚠️ 成功后服务端**还会广播** `message_edited`(§3.8)——发起的这条连接自己也会收到它。
|
||||
* 于是「REST 响应」与「广播帧」会把同一件事说两遍,两处必须收敛成一次状态变更
|
||||
* (见 [top.yeij.cyrene.data.repository.applyMessageEdit])。
|
||||
*/
|
||||
@PATCH("api/v1/messages/{id}")
|
||||
suspend fun updateMessage(
|
||||
@Path("id") messageId: String,
|
||||
@Body body: UpdateMessageRequest,
|
||||
): Response<ApiEnvelope<HistoryMessageDto>>
|
||||
|
||||
/**
|
||||
* 删除单条消息(D3,契约 §1 行 6b):`DELETE /api/v1/messages/{id}` →
|
||||
* `{data:{deleted:<消息 id>}}`。
|
||||
*
|
||||
* 失败面:消息不存在 → `404 not_found`;未注入 HistoryStore → `501`;权限不足 → `403`。
|
||||
* 成功后同样会广播 `message_deleted`(§3.8,只带被删消息的 `id`)。
|
||||
*/
|
||||
@DELETE("api/v1/messages/{id}")
|
||||
suspend fun deleteMessage(
|
||||
@Path("id") messageId: String,
|
||||
): Response<ApiEnvelope<DeleteMessageResponse>>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -47,6 +47,26 @@ data class AdminTraceEventDto(
|
||||
val props: Map<String, JsonElement>? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 一条后端日志,**两个来源共用这一个形状**(api-client §2「可观测性契约」与 §3.12):
|
||||
* `GET /api/v1/admin/observability/logs` 的 `data[]`,以及 `observability_log` 帧的
|
||||
* `data.entries[]`。后端是同一个生产者(`gateway.AdminLogEntry`,`adminLogOf` 映射),
|
||||
* 契约也明说帧「与 REST 同形」——所以这里只有一份 DTO,不另建一份等价形状。
|
||||
*
|
||||
* - [time] 是 **RFC3339(UTC,秒精度)**——与 `trace.startedAt` / `audit.when` 同源同形状,
|
||||
* 而**不是** backthink 那种 Unix 毫秒([AdminBackthinkItemDto.at]),两者不可共用解析器。
|
||||
* - [level] ∈ `debug|info|warn|error`,是**开放集合**:本层原样保留,未知等级既不丢弃也不归一。
|
||||
* - [source] 是 logger 的来源标签,记录没带来源时是空串(不是 null)。
|
||||
* - 条目**没有 id**:去重只能按这四个字段(见 `ui/admin/AdminOverviewMerge.kt`)。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminLogEntryDto(
|
||||
val time: String = "",
|
||||
val level: String = "",
|
||||
val source: String = "",
|
||||
val msg: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* 人格摘要(用户侧 `GET /api/v1/personas` 与 admin 侧 `GET /api/v1/admin/personas` 同形)。
|
||||
* admin 侧多一个 [thinking];未配置该人格时**该键完全不出现**,故为 nullable。
|
||||
|
||||
@@ -20,6 +20,10 @@ import kotlinx.serialization.Serializable
|
||||
* ⚠️ 本项目 `ApiClientFactory.json` 用 kotlinx 默认的 `encodeDefaults = false`:
|
||||
* 取值等于默认值的字段**整键省略**。因此**纯图片消息**(`text == ""`)线上不会带 `text` 键——
|
||||
* 服务端 Go 侧零值同为空串,语义完全等价(已按此实现,见 [ChatDtoRealPayloadTest])。
|
||||
*
|
||||
* [documents] 同理:默认空列表 ⇒ **不带附件的请求线上与加入该字段之前逐字节相同**
|
||||
* (服务端 `Documents []chatDocumentRef \`json:"documents,omitempty"\`` 零值也省略,
|
||||
* 见 `DocumentDtoTest` 里拿字面量 JSON 对比的那条钉子)。
|
||||
*/
|
||||
@Serializable
|
||||
data class ChatRequest(
|
||||
@@ -30,6 +34,28 @@ data class ChatRequest(
|
||||
val mode: String? = null,
|
||||
val stream: Boolean? = null,
|
||||
val clientMsgId: String? = null,
|
||||
/**
|
||||
* 本回合附带的文档引用(契约 v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」),形状见 [DocumentRefDto]):**≤5 条**,
|
||||
* 每条**只给服务端存储名**(`{"name":"<32hex>.<ext>"}`),**不收 URL**。
|
||||
*
|
||||
* 上行只发 `name`:其它字段服务端一律不采信并覆盖(字节嗅探是唯一的类型裁决)。
|
||||
* 空列表 → 整键省略(`encodeDefaults = false`),旧请求逐字节不变。
|
||||
*
|
||||
* ⚠️ **带附件一律走 REST**:文档本体经 `POST /api/v1/documents` 上传,引用随
|
||||
* `POST /api/v1/chat` 发出([top.yeij.cyrene.domain.chat.DocumentDelivery] 的判定);
|
||||
* WS `chat` 帧的 4096 字节读上限仍在,且上行帧里没有承载附件的余地。
|
||||
*/
|
||||
val documents: List<DocumentRefDto> = emptyList(),
|
||||
/**
|
||||
* **请求把本条作为独立一路处理**(忙时并行规范 §2.2,REST 与 WS `chat` **同形**)。
|
||||
*
|
||||
* `null` = **整键省略**(`encodeDefaults = false`),与加入该字段之前**逐字节相同**:
|
||||
* 用户把设置页那栏「她忙时发消息」选成「**并行**」**且会话正忙**时才给 `true`(判据是纯函数
|
||||
* `parallelForSend`;另外两档、以及空闲时都不带这个键)。
|
||||
* 服务端照准与否都不报错——额度满时落回排队并回 `202 {queued:true}`(伴一条
|
||||
* `pending_ack{reason:"parallel_limit"}`),已被在飞路 steer 吃进时回 `200 {stream:true}`。
|
||||
*/
|
||||
val parallel: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 文档 DTO —— 「文件进对话」的**唯一形状落点**(契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**)。
|
||||
*
|
||||
* ## 形状从哪来
|
||||
* 写这份代码时 `Cyrene_v2/Docs/api-client.md` 的最新契约版本仍是 **v0.58**(里面**没有**
|
||||
* `documents` 任何形状)。因此形状依据是
|
||||
* `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 **回执五**(2026-10-04 深夜,
|
||||
* 标题即「一条契约形状修正」),并用后端仓库已落库的三个切片逐字段核对过:
|
||||
* `407c934`(S1 存储端点)/ `a4789ec`(S3-B 网关入口)/ `5b2b385`(S3.5 文档多值化)。
|
||||
*
|
||||
* **形状要变只改本文件。** 上行(`documents:[{name}]`)与下行/历史(`documents:[{…}]`)
|
||||
* 是**同一个元素类型**([DocumentRefDto]):服务端两条入口共用一处解析,这里也共用一个类。
|
||||
*
|
||||
* ## 回执五 §二「请以此为准」的三条
|
||||
* 1. **单对象 `document` 已作废**,一律**数组** `documents`(≤5)——修的是「一回合 5 个附件,
|
||||
* 历史与后续上下文里只留第一个」这个实测确认的缺陷;
|
||||
* 2. 上行元素**只给服务端存储名 `name`,绝不收 URL**(不收 URL 是为了不重开 `F3` 那种
|
||||
* 「服务端代取 URL」的 SSRF 面);谎报的 `mime`/`url`/`size` 会被服务端覆盖,
|
||||
* **类型裁决只有字节嗅探一条**(客户端声明的 `Content-Type` 与文件名只作参考、可被否决,
|
||||
* 所以一个 `data.json` 可能被回成 `text/plain`);
|
||||
* 3. 下行/历史元素只回**引用与统计,绝不回正文**(吸取 `G3`「base64 原样落库、每页历史重传」
|
||||
* 的教训)。`status:"unreadable"` 才是「读不到这个文件」的可观测信号,**不是** `truncated`
|
||||
* (`truncated` 只说明注入正文被预算截断,正文照样进得了上下文)。
|
||||
* **契约 v0.63(后端 `902b6ee`)在此之上加了可选 [DocumentRefDto.reason]**:读不到时随
|
||||
* `status:"unreadable"` 一起下发的**服务端原话**,客户端原样显示(详见该字段的 KDoc)。
|
||||
*
|
||||
* ## 客户端必须两种都吃的一件事
|
||||
* 「0 个附件」这一形状在后端**尚未定死**(回执五原文:期望「不出现或空数组」,S3.5 回报里定死)。
|
||||
* 因此本包的解析一律按**缺失键 → 空列表**、**空数组 → 空列表**两条都收:
|
||||
* 详见 [HistoryMessageDto.documents] 与 [ChatRequest.documents] 的默认值。
|
||||
*/
|
||||
@Serializable
|
||||
data class DocumentUploadResponse(
|
||||
/** 服务端存储名:32 位小写十六进制 + 白名单扩展名。**上行引用时唯一能用的字段**。 */
|
||||
val name: String = "",
|
||||
/**
|
||||
* **相对**同源路径(`/api/v1/documents/<name>`),与图片上传的**绝对** URL 不同:
|
||||
* 读侧要 Bearer `owner|admin`,所以它不是「贴进 `<img>` 就能取」的能力 URL,绝对化没有意义。
|
||||
* 客户端真正要用的是 [name]。
|
||||
*/
|
||||
val url: String = "",
|
||||
/** 服务端**字节嗅探**所得的基类型(不是客户端声明的),如 `text/plain`。 */
|
||||
val mime: String = "",
|
||||
val size: Long = 0,
|
||||
/** 内容寻址的哈希(64 位小写十六进制):落盘名就是它的前 32 位 + 扩展名。 */
|
||||
val sha256: String = "",
|
||||
/**
|
||||
* `true` = 同一份字节(同扩展名)之前已经上传过:服务端回 `200` + **同一个** `name`,
|
||||
* **必须当成功处理,不是错误**。`false` = 首次上传(`201`)。
|
||||
*/
|
||||
val deduped: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 一条文档引用。**上行只发 [name]**(其余字段服务端不采信、会覆盖),
|
||||
* 下行/历史则带上全部统计字段。
|
||||
*
|
||||
* 字段可出现性(后端 `history.DocumentRef` 的 `omitempty` 口径,逐字段核对过):
|
||||
* `name` / `mime` / `size` **恒在**;`chars` / `truncated` / `status` / `reason` 为 Go 的 `omitempty`
|
||||
* —— 零值(0 / false / "" / nil)时**整键省略**,所以取值一律给默认值,解码不会因缺键失败。
|
||||
*/
|
||||
@Serializable
|
||||
data class DocumentRefDto(
|
||||
/** 服务端存储名(`<32hex>.<ext>`)。客户端**本地**的原始文件名不在这里,也不该在这里。 */
|
||||
val name: String = "",
|
||||
val mime: String = "",
|
||||
val size: Long = 0,
|
||||
/** 本次**注入上下文**的字符数(被预算截断者填截断后的值);`null` = 服务端未报告。 */
|
||||
val chars: Int? = null,
|
||||
/**
|
||||
* 注入正文是否被预算截断:`true` 只说明进上下文的那段被截短了,**不是**「读不到」——
|
||||
* 读不到看 [status](`unreadable`)。两个信号刻意分开,别互相顶替。
|
||||
*/
|
||||
val truncated: Boolean = false,
|
||||
/**
|
||||
* 抽取状态:`extracted` / `listed` / `unreadable`(见
|
||||
* [top.yeij.cyrene.domain.chat.DocumentStatus])。**开放集合,不用 enum**:
|
||||
* 未知值**原样保留**(不丢弃、不报错、不占位符),由客户端决定如何显示。
|
||||
*/
|
||||
val status: String = "",
|
||||
/**
|
||||
* **读不到的原因**(契约 v0.63 新增,后端 `902b6ee`):`status == "unreadable"` 时下发的
|
||||
* 那句**服务端原话**;`status == "extracted"` 时**整键缺席**,因此是可选字段。
|
||||
*
|
||||
* 两句纪律:
|
||||
* 1. **原样显示**。[reason] 与后端**注入模型清单里的那 10 条是同一份字符串**(逐字相同,
|
||||
* 最长的一条 40+ 字),客户端**不替服务端改写、不截断、不归纳**成自己的话——
|
||||
* 本端能编的只有像 [top.yeij.cyrene.domain.chat.DocumentPreviewGate.refusalMessage]
|
||||
* 那样「按类型泛泛说一句」,比这里的原因粗得多;
|
||||
* 2. **绝不做整句相等判断**。分类若真的需要分支,只允许用**稳定子串**(如「需要 OCR」)——
|
||||
* 整句比对会在后端改一个字时静默失效,且失败的样子是「显示成兜底那句话」,
|
||||
* 看起来完全正常。
|
||||
*/
|
||||
val reason: String? = null,
|
||||
)
|
||||
@@ -5,6 +5,13 @@ import kotlinx.serialization.Serializable
|
||||
/**
|
||||
* `GET /api/v1/sessions/{id}/messages` 单条消息(api-reference §3.4)。
|
||||
* 服务端按 `createdAt` 倒序返回,渲染前需 reverse / merge。
|
||||
*
|
||||
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
|
||||
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
|
||||
*
|
||||
* [voice] 从契约 v0.97(后端 M44 `fb55769`)起也**不再是例外**:REST 四路、握手 `history` 回放、
|
||||
* `message_persisted` / `channel_persisted` 落库帧**都填它**,第一条帧就带
|
||||
* (前提与理由写在 [voice] 的 KDoc 里)。
|
||||
*/
|
||||
@Serializable
|
||||
data class HistoryMessageDto(
|
||||
@@ -27,5 +34,91 @@ data class HistoryMessageDto(
|
||||
* 空 = 这条消息没有键(老客户端发的、IM 镜像进来的、v0.56 之前的历史行)。
|
||||
*/
|
||||
val clientMsgId: String? = null,
|
||||
/**
|
||||
* 这条消息携带的文档引用与抽取统计(契约 v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」);形状见 [DocumentRefDto])。
|
||||
*
|
||||
* 默认 [emptyList] 同时吃掉后端**尚未定死**的两种「0 个附件」形状:
|
||||
* **键缺失 → 空列表**(后端 `historyMessage.Documents` 带 `omitempty`,空/缺失即整键省略——
|
||||
* 这一点已对着 `5b2b385` 的实现与 `history_document_test.go` 的断言核对过)、
|
||||
* **空数组 → 空列表**(S3.5 之前写下的行、以及将来若改回恒下发 `[]`)。
|
||||
*
|
||||
* 只回引用与统计、**绝不回正文**;未知 `status` **原样保留**(开放集合,见 [DocumentRefDto])。
|
||||
* WS `message_persisted` 与握手 `history` 回放用的是同一个 DTO,因此这一处解析两条路都覆盖。
|
||||
*/
|
||||
val documents: List<DocumentRefDto> = emptyList(),
|
||||
val createdAt: Long,
|
||||
/**
|
||||
* 产出 / 吃进这条消息的那一路的回合标识(忙时并行规范 §2.7,**纯增量**)。
|
||||
*
|
||||
* 语义按角色分(规范 §2.7 明写):
|
||||
* - **user 行** = 吃进它 / 起它的那一路的 `turnId`(被 steer 吃进的行 = 吃进它的那一轮);
|
||||
* - **assistant 行** = 产出它的那一路的 `turnId`。
|
||||
*
|
||||
* 于是重连后能把「回复 ↔ 提问」精确配对,两条并行回复也天然分属两个 `turnId`(分桶有据),
|
||||
* **不必靠时间戳猜**(历史顺序的真源是 `(created_at, rowid)` = **完成先后**,
|
||||
* 不承诺与提问相邻)。
|
||||
*
|
||||
* **旧行整键省略**(不是空串,规范 §2.7 末条)⇒ [null] 时客户端回落既有的「按顺序渲染」,
|
||||
* 与加入这一列之前逐字相同。四处同源:`message_persisted` / `GET /messages` / 握手 `history`
|
||||
* 回放 / `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,
|
||||
)
|
||||
|
||||
/**
|
||||
* `PATCH /api/v1/messages/{id}` 的请求体(D3,契约 §1 行 6a)。
|
||||
*
|
||||
* **只有 `content` 一个键**:服务端按它改 `content`,role / 时间戳 / 平台 / 图片 / 附件一律保持原值,
|
||||
* 所以这里**不能**多带任何字段(多带也不会被采纳,只会让后来的读者以为客户端能改这些)。
|
||||
*
|
||||
* 服务端口径:非空、≤ **4000 rune**;空 / 纯空白 / 超长回 `400 bad_request`。
|
||||
* [content] 要发**用户原样输入的内容**(含首尾空白)——服务端存的就是这个字符串,
|
||||
* 客户端替它 trim 一次等于悄悄改掉用户的消息。
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateMessageRequest(
|
||||
val content: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `DELETE /api/v1/messages/{id}` → `200 {"data":{"deleted":"<消息 id>"}}`(契约 §1 行 6b)。
|
||||
*
|
||||
* 与 [DeleteSessionResponse] 逐字同形,但**刻意分开**:这两个 id 的命名空间和「删掉了什么」
|
||||
* 是两件事,共用一个类型会让调用方以为删的是会话。
|
||||
*/
|
||||
@Serializable
|
||||
data class DeleteMessageResponse(
|
||||
val deleted: String,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `GET /api/v1/personas`(**用户侧**角色目录,api-client §1 行 9)的一项。
|
||||
*
|
||||
* 与 admin 侧的 [PersonaSummaryDto] 同源同形,但**用户侧不下发 admin 专属的 `thinking`**
|
||||
* ——这正是不能拿 admin 端点当用户侧端点用的原因之一(用户侧不暴露模型调用旋钮)。
|
||||
*
|
||||
* [mode] / [mood] 都是**开放集合**:未知取值原样保留(不丢弃、不报错、不占位符),渲染时才映射。
|
||||
* `mood` 为空串是「该角色暂无被跟踪的情绪」,不是异常,也不伪造默认值。
|
||||
* [chatBackground] 与 admin 侧共用 [ChatBackgroundDto](同一个后端结构)。
|
||||
*/
|
||||
@Serializable
|
||||
data class UserPersonaDto(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
val character: String = "",
|
||||
val isDefault: Boolean = false,
|
||||
val mode: String = "",
|
||||
val memoryNamespace: String = "",
|
||||
val avatar: String? = null,
|
||||
val chatBackground: ChatBackgroundDto? = null,
|
||||
val mood: String? = null,
|
||||
)
|
||||
@@ -31,6 +31,13 @@ data class SessionSummaryDto(
|
||||
* 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。
|
||||
*/
|
||||
val unreadCount: Int = 0,
|
||||
/**
|
||||
* 该会话绑定的角色 id(v0.42,可选);**空/缺省 = 继承全局默认角色**。
|
||||
*
|
||||
* 只读字段:会话角色**只在创建时**写入,之后不可变(`PATCH` 一旦收到 `personaId` 一律 400),
|
||||
* 所以它不可能被本端改。默认值是为了旧快照仍能解码(同 [unreadCount] 的理由)。
|
||||
*/
|
||||
val personaId: String? = null,
|
||||
) {
|
||||
val isMain: Boolean
|
||||
get() = type == TYPE_MAIN || id == MAIN_SESSION_ID
|
||||
@@ -42,10 +49,32 @@ data class SessionSummaryDto(
|
||||
}
|
||||
}
|
||||
|
||||
/** `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:<uuid>"}}`。 */
|
||||
/**
|
||||
* `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:<uuid>"}}`。
|
||||
*
|
||||
* [personaId] 只在**创建时显式绑了角色**才回显(v0.42);省略时响应形状与 v0.41 完全一致,
|
||||
* 所以它是可空字段,不能拿它的有无去判断别的事。
|
||||
*/
|
||||
@Serializable
|
||||
data class CreateSessionResponse(
|
||||
val sessionId: String,
|
||||
val personaId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/v1/sessions` 的请求体(v0.42:创建时绑定角色)。
|
||||
*
|
||||
* 服务端把「省略 `personaId`」与「`personaId` 为空」当成同一件事:**不绑**
|
||||
* (`persona_id=''`,继承全局默认角色)。`personaId` 为**未知角色**时回 `400 bad_request`。
|
||||
*
|
||||
* 生产路径上 null 不会走到这里——仓库对 null 走的是**完全不带 body** 的重载
|
||||
* (与本端支持选角之前的线上请求逐字节相同)。这里保留可空字段,是为了让
|
||||
* 「null 序列化后整个键缺席」这件事有一处**可被测试钉住**的表达
|
||||
* (生产 `Json` 的 `explicitNulls = false`)。
|
||||
*/
|
||||
@Serializable
|
||||
data class CreateSessionRequest(
|
||||
val personaId: String? = 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,
|
||||
)
|
||||
@@ -3,6 +3,7 @@ package top.yeij.cyrene.data.repository
|
||||
import top.yeij.cyrene.data.api.AdminApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
|
||||
@@ -30,6 +31,16 @@ class AdminRepository(
|
||||
apiCall { api.trace(limit.coerceIn(1, MAX_TRACE_LIMIT)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /observability/logs`(后端日志 tail)。`limit` 钳制在 [1, [MAX_LOG_LIMIT]] 后
|
||||
* **显式**发出去(服务端对 ≤0 / 非法值有一条「回落缺省」的兜底,这里不依赖它):
|
||||
* 这样「向服务端要多少条」与客户端的保留窗口(`LOG_WINDOW`)永远同源。
|
||||
*/
|
||||
suspend fun logs(limit: Int = DEFAULT_LOG_LIMIT): Result<List<AdminLogEntryDto>> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.logs(limit.coerceIn(1, MAX_LOG_LIMIT)) }
|
||||
}
|
||||
|
||||
suspend fun personas(): Result<List<PersonaSummaryDto>> = withContext(dispatcher) {
|
||||
apiCall { api.personas() }
|
||||
}
|
||||
@@ -48,5 +59,14 @@ class AdminRepository(
|
||||
|
||||
/** 服务端 `?limit=` 上限。 */
|
||||
const val MAX_TRACE_LIMIT = 500
|
||||
|
||||
/**
|
||||
* 日志拉取条数:契约的缺省值,也就是「最近日志」区块的保留窗口
|
||||
* (`ui/admin/AdminOverviewMerge.kt` 的 `LOG_WINDOW`)与推送帧的单帧上限。
|
||||
*/
|
||||
const val DEFAULT_LOG_LIMIT = 200
|
||||
|
||||
/** 服务端 logs `?limit=` 上限(契约 §2)。 */
|
||||
const val MAX_LOG_LIMIT = 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
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.domain.chat.DocumentAttachments
|
||||
import top.yeij.cyrene.domain.chat.DocumentSource
|
||||
import top.yeij.cyrene.domain.chat.ImageDataUri
|
||||
import top.yeij.cyrene.domain.chat.ImageFormats
|
||||
import top.yeij.cyrene.domain.chat.ImageNames
|
||||
import top.yeij.cyrene.domain.chat.ImageSource
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** 待发附件的本地读取失败(授权失效 / 文件已被删 / provider 报错)。 */
|
||||
class LocalDocumentUnavailableException : Exception("local document unreadable")
|
||||
|
||||
/** 待发附件超过本端能处理的体积上限(服务端另有 `413 document_too_large`)。 */
|
||||
class LocalDocumentTooLargeException : Exception("local document too large")
|
||||
|
||||
/** 缓存目录写不进去(连预览都做不了——字节拿到了也没地方交给别的 App)。 */
|
||||
class DocumentCacheUnavailableException : Exception("document cache unavailable")
|
||||
|
||||
/** 服务端返回的图片字节超过本端一次性读取的上限(见 [ImageCacheStore] 那把尺子)。 */
|
||||
class ImageTooLargeException : Exception("image too large")
|
||||
|
||||
/**
|
||||
* `cacheDir/documents/` 的路径推导、命中判定与原子落盘(纯逻辑 + 文件操作,可单测)。
|
||||
*
|
||||
* ## 为什么命中就等于「永远有效」
|
||||
* 服务端落盘名是**内容寻址**的(`sha256(bytes)[:32] + ext`,契约 v0.59 §1.2 ①),且读侧响应带
|
||||
* `Cache-Control: private, max-age=86400, immutable`(§1.2 ②)。名字由内容决定 ⇒ **同一个
|
||||
* `name` 的字节永不改变**。所以这里不做任何过期 / 校验 / 大小比对:文件在且非空就是命中。
|
||||
* 反过来说,服务端就算把源文件删了,本地这份也仍是当初的那一份内容(这正是缓存的意义)。
|
||||
*
|
||||
* ## 为什么必须原子落盘
|
||||
* 下载中途失败(断网 / 进程被杀)会留下一个**截断的文件**。如果它落在正式名下,下一次打开
|
||||
* 就会「命中」并把这个半截文件当成完整内容显示——**静默的错误内容比一次失败糟得多**。
|
||||
* 因此先写 `.part` 临时文件,成功了再改名;任何一步失败都把临时文件删掉。
|
||||
*/
|
||||
object DocumentCache {
|
||||
|
||||
/** FileProvider(见 `AndroidManifest.xml` 与 `res/xml/file_paths.xml`)暴露的那一层目录名。 */
|
||||
const val DIRECTORY_NAME: String = "documents"
|
||||
|
||||
/** 缓存目录:`<cacheDir>/documents`。 */
|
||||
fun directory(cacheDir: File): File = File(cacheDir, DIRECTORY_NAME)
|
||||
|
||||
/**
|
||||
* 某个名字在缓存里的路径;名字**不安全**时回 `null`。
|
||||
*
|
||||
* 缓存目录是唯一被 FileProvider 暴露出去的根,所以这里对名字的容忍度取最小:单段、
|
||||
* 非空、不是 `.` / `..`、不含分隔符与 NUL。服务端形态(`<32hex>.<ext>`)与
|
||||
* [localName] 生成的形态都天然满足;不满足的名字一律拒绝,绝不用它拼出一条能爬出
|
||||
* `documents/` 的路径。
|
||||
*/
|
||||
fun fileFor(cacheDir: File, name: String): File? =
|
||||
if (isSafeName(name)) File(directory(cacheDir), name) else null
|
||||
|
||||
/** 名字是否可以安全地当**单个**文件名用。 */
|
||||
fun isSafeName(name: String): Boolean =
|
||||
name.isNotBlank() &&
|
||||
name != "." &&
|
||||
name != ".." &&
|
||||
name.none { it == '/' || it == '\\' || it == '\u0000' }
|
||||
|
||||
/** 命中判定:**非空文件**才算。0 字节(失败落盘 / 被外部清空的残留)一律当未命中。 */
|
||||
fun isHit(file: File): Boolean = file.isFile && file.length() > 0L
|
||||
|
||||
/**
|
||||
* 原子落盘:写临时文件 → 改名到 [file]。失败时**不留半截文件**在任何正式名下。
|
||||
*
|
||||
* @throws IOException 写不动 / 改名失败(临时文件已清掉)。
|
||||
*/
|
||||
fun write(file: File, bytes: ByteArray) {
|
||||
val directory = file.parentFile ?: throw IOException("cache file has no parent: $file")
|
||||
if (!directory.isDirectory && !directory.mkdirs()) {
|
||||
throw IOException("cannot create cache directory: $directory")
|
||||
}
|
||||
val temporary = File.createTempFile(TEMP_PREFIX, TEMP_SUFFIX, directory)
|
||||
try {
|
||||
temporary.outputStream().use { it.write(bytes) }
|
||||
moveInto(temporary, file)
|
||||
} catch (e: Exception) {
|
||||
temporary.delete()
|
||||
throw e
|
||||
} finally {
|
||||
// 成功时临时文件已被改名(不再存在);失败时这里再兜一次,别把 .part 留在缓存里。
|
||||
if (temporary.exists()) temporary.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地待发附件的缓存名:**也按内容寻址**(`sha256(bytes)[:32] + 扩展名`)。
|
||||
*
|
||||
* 复用服务端那条规则有两个好处:同一份字节重复打开只落一份;名字形态与服务端存储名同构,
|
||||
* 「打开 / 保存」两条路不必分支处理。扩展名取自待发附件的显示名(本地这一侧它是可信的,
|
||||
* 用户选的就是它)——拿不到就用空扩展名,**不猜**。
|
||||
*/
|
||||
fun localName(bytes: ByteArray, displayName: String): String =
|
||||
sha256Hex(bytes).take(NAME_HEX_LENGTH) + extensionOf(displayName)
|
||||
|
||||
private fun moveInto(from: File, to: File) {
|
||||
try {
|
||||
Files.move(
|
||||
from.toPath(),
|
||||
to.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(from.toPath(), to.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256Hex(bytes: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
|
||||
/** 显示名里的扩展名(带点、小写、≤8 个字母数字);拿不到就回空串。 */
|
||||
private fun extensionOf(displayName: String): String {
|
||||
val extension = displayName.substringAfterLast('.', "").lowercase()
|
||||
val usable = extension.isNotEmpty() &&
|
||||
extension.length <= MAX_EXTENSION_LENGTH &&
|
||||
extension.all { it.isLetterOrDigit() }
|
||||
return if (usable) ".$extension" else ""
|
||||
}
|
||||
|
||||
private const val TEMP_PREFIX = "cache-"
|
||||
private const val TEMP_SUFFIX = ".part"
|
||||
private const val NAME_HEX_LENGTH = 32
|
||||
private const val MAX_EXTENSION_LENGTH = 8
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存里的一份文档:[file] 是落盘的那一份(交给 FileProvider / 写进下载目录),[bytes] 是它的
|
||||
* 内容(预览切段用)。
|
||||
*
|
||||
* 两者一起返回是刻意的:下载刚拿到的字节与随后写进缓存的是**同一份**,再读一次盘只是白花
|
||||
* 一次 20 MiB 的 IO。
|
||||
*/
|
||||
class CachedDocument(val file: File, val bytes: ByteArray)
|
||||
|
||||
/**
|
||||
* 「按需取字节 + 落盘缓存」的取数层(`GET /api/v1/documents/{name}` 的**第一个调用方**)。
|
||||
*
|
||||
* 服务端([DocumentRepository.download])与本地(待发附件的 `content://`)两条来源在这里汇成
|
||||
* 同一个结果。来源判定的**唯一**落点是 [DocumentSource](见 `DocumentTarget.of`)——本类只按
|
||||
* 它分支,不再自己判断「这是服务端还是本地」。
|
||||
*
|
||||
* [openLocal] 收成一个 `String -> InputStream?`,把 `ContentResolver` 挡在类外:这样本类可以在
|
||||
* 单测里用临时目录 + 假流跑完(Android 的 `Uri` / `ContentResolver` 在 JVM 单测里是 stub,
|
||||
* 一碰就抛)。
|
||||
*/
|
||||
class DocumentCacheStore(
|
||||
private val repository: DocumentRepository,
|
||||
private val cacheDir: File,
|
||||
private val openLocal: (String) -> InputStream?,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 取回一份附件的字节并确保它已落进缓存。
|
||||
*
|
||||
* 服务端来源先看缓存([DocumentCache.isHit])——命中就**一个请求都不发**;本地来源没有
|
||||
* 「命中」可言(URI 指向的内容可能变),每次都按当前字节重新算内容寻址名。
|
||||
*/
|
||||
suspend fun load(source: DocumentSource): Result<CachedDocument> = withContext(dispatcher) {
|
||||
when (source) {
|
||||
is DocumentSource.Server -> loadServer(source.name)
|
||||
is DocumentSource.Local -> loadLocal(source)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadServer(name: String): Result<CachedDocument> {
|
||||
val file = DocumentCache.fileFor(cacheDir, name)
|
||||
// 名字不安全 = 服务端给了一个本端不敢拼路径的名字;判解析失败,绝不退回本地文件名。
|
||||
?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
if (DocumentCache.isHit(file)) {
|
||||
val cached = runCatching { file.readBytes() }.getOrNull()
|
||||
if (cached != null) return Result.success(CachedDocument(file, cached))
|
||||
// 缓存读不动(被外部清理 / 权限异常):删掉重下,绝不当成「空文件」返回。
|
||||
file.delete()
|
||||
}
|
||||
val bytes = repository.download(name).getOrElse { return Result.failure(it) }
|
||||
return cache(file, bytes)
|
||||
}
|
||||
|
||||
private fun loadLocal(source: DocumentSource.Local): Result<CachedDocument> {
|
||||
val bytes = try {
|
||||
val input = openLocal(source.uri)
|
||||
?: return Result.failure(LocalDocumentUnavailableException())
|
||||
input.use { stream ->
|
||||
// 与上传路径同一把尺子:本端能处理的体积上限就是附件上限。
|
||||
readBounded(stream, DocumentAttachments.MAX_FILE_BYTES)
|
||||
} ?: return Result.failure(LocalDocumentTooLargeException())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
return Result.failure(LocalDocumentUnavailableException())
|
||||
}
|
||||
val file = DocumentCache.fileFor(cacheDir, DocumentCache.localName(bytes, source.displayName))
|
||||
?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
// 内容寻址:同一份字节已经落过盘就不重写(重复打开一份大附件不该反复写 20 MiB)。
|
||||
if (!DocumentCache.isHit(file)) return cache(file, bytes)
|
||||
return Result.success(CachedDocument(file, bytes))
|
||||
}
|
||||
|
||||
private fun cache(file: File, bytes: ByteArray): Result<CachedDocument> = try {
|
||||
DocumentCache.write(file, bytes)
|
||||
Result.success(CachedDocument(file, bytes))
|
||||
} catch (_: Exception) {
|
||||
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——预览本身也是靠缓存文件那条路
|
||||
// 把完整内容交给别的 App 的,所以这里不回退成「只看得到一半」。
|
||||
file.delete()
|
||||
Result.failure(DocumentCacheUnavailableException())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片字节的取数与落盘(读侧是 `GET /api/v1/files/{name}`)。
|
||||
*
|
||||
* ## 为什么与附件挤在同一个缓存层
|
||||
* 图片与附件落的是**同一个目录**(`cacheDir/documents/`),因此共用同一个 FileProvider 根。
|
||||
* 「缓存目录是唯一被暴露出去的根」这条不变量只允许有一处实现:名字规则、命中判定、原子落盘
|
||||
* 三条全部复用 [DocumentCache]——图片只是多用了「名字推不出来时按内容寻址」这一条。
|
||||
* 不新开 `images/` 目录也不改 `file_paths.xml`:暴露面一个字节都没变。
|
||||
*
|
||||
* ## 三种来源各自的取字节方式
|
||||
* - [ImageSource.Server]:先按 URL 里的名字看缓存(**命中一个请求都不发**,名字由内容决定
|
||||
* ⇒ 命中永不失效),未命中才 GET 那个绝对地址;
|
||||
* - [ImageSource.Inline]:小图内联进历史的那份 `data:` URI,字节就在字符串里,**不联网**;
|
||||
* - [ImageSource.Local]:相册选择器的 `content://` / 分享落地的 `file://`,按 URI 现读。
|
||||
*
|
||||
* ## 为什么用一条**裸的** OkHttp 客户端
|
||||
* 图片读侧是 128-bit 随机名构成的**能力 URL**,服务端刻意不校验 Bearer(`<img>` 也带不了头)。
|
||||
* 所以这里不发 `Authorization`:把主人的 token 发到一个免鉴权端点上没有任何意义,只多一个泄漏面。
|
||||
* ⚠️ 文档读侧(`GET /api/v1/documents/{name}`)**要** Bearer,那条路走 [DocumentCacheStore]。
|
||||
*
|
||||
* ## 为什么不复用 Coil 已经取过的那一份
|
||||
* 气泡缩略图是 Coil 拉的,它的磁盘缓存**不给**「这一份普通文件在哪」这种契约(键与落盘格式都是
|
||||
* 它内部的事)。而两条出口要交给系统别的 App 的恰恰是一份普通文件(FileProvider 只认路径)。
|
||||
* 所以这里自己取一次、按名字落盘:代价是**首次打开一张服务端图会多下一次**(第二次起走本地
|
||||
* 缓存),换来的是「预览看到的、打开给出的、保存写下的」是同一份字节,不会出现三条路各下一个。
|
||||
*
|
||||
* [download] 与 [openLocal] 都收成 lambda,把 OkHttp 与 `ContentResolver` 挡在类外:本类因此
|
||||
* 可以在单测里用临时目录 + 假流跑完(`android.net.Uri` 在 JVM 单测里是 stub,一碰就抛)。
|
||||
*/
|
||||
class ImageCacheStore(
|
||||
private val cacheDir: File,
|
||||
private val openLocal: (String) -> InputStream?,
|
||||
private val download: suspend (String) -> Result<ByteArray> = ::httpGetImage,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 取回一张图的字节并确保它已落进缓存。
|
||||
*
|
||||
* 三种来源的产物是同一个形状([CachedDocument]):文件名 + 字节。这样「用其它应用打开 /
|
||||
* 保存到下载目录」两条出口不必按来源分支——交给 FileProvider 的永远是缓存里那一份。
|
||||
*/
|
||||
suspend fun load(source: ImageSource): Result<CachedDocument> = withContext(dispatcher) {
|
||||
when (source) {
|
||||
is ImageSource.Server -> loadServer(source.raw)
|
||||
is ImageSource.Inline -> loadInline(source.raw)
|
||||
is ImageSource.Local -> loadLocal(source.raw)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadServer(url: String): Result<CachedDocument> {
|
||||
val name = ImageNames.fromServerUrl(url)
|
||||
if (name != null) cachedHit(name)?.let { return it }
|
||||
val bytes = download(url).getOrElse { return Result.failure(it) }
|
||||
// 名字推不出来 / 拼不出路径(形态陌生、含可疑字符)时按**字节**内容寻址:
|
||||
// 绝不拿一个不可信的名字去拼路径,也绝不因此拒绝打开这张图。
|
||||
return write(name?.let { DocumentCache.fileFor(cacheDir, it) } ?: contentAddressed(bytes), bytes)
|
||||
}
|
||||
|
||||
private fun loadInline(dataUri: String): Result<CachedDocument> {
|
||||
val bytes = ImageDataUri.bytes(dataUri)
|
||||
?: return Result.failure(LocalDocumentUnavailableException())
|
||||
return write(contentAddressed(bytes), bytes)
|
||||
}
|
||||
|
||||
private fun loadLocal(uri: String): Result<CachedDocument> {
|
||||
val bytes = try {
|
||||
val input = openLocal(uri)
|
||||
?: return Result.failure(LocalDocumentUnavailableException())
|
||||
input.use {
|
||||
// 与附件同一把尺子:本端能一次读进内存的上限就是附件上限。
|
||||
readBounded(it, DocumentAttachments.MAX_FILE_BYTES)
|
||||
} ?: return Result.failure(LocalDocumentTooLargeException())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
return Result.failure(LocalDocumentUnavailableException())
|
||||
}
|
||||
return write(contentAddressed(bytes), bytes)
|
||||
}
|
||||
|
||||
/** 缓存命中:名字可信、文件在且非空、且真的读得出来。读不动就删掉重来,绝不当成空图返回。 */
|
||||
private fun cachedHit(name: String): Result<CachedDocument>? {
|
||||
val file = DocumentCache.fileFor(cacheDir, name) ?: return null
|
||||
if (!DocumentCache.isHit(file)) return null
|
||||
val bytes = runCatching { file.readBytes() }.getOrNull() ?: run {
|
||||
file.delete()
|
||||
return null
|
||||
}
|
||||
return Result.success(CachedDocument(file, bytes))
|
||||
}
|
||||
|
||||
/** 内容寻址的缓存名(与服务端文档同一规则):`sha256(bytes)[:32] + 嗅探所得的扩展名`。 */
|
||||
private fun contentAddressed(bytes: ByteArray): File? =
|
||||
DocumentCache.fileFor(
|
||||
cacheDir,
|
||||
DocumentCache.localName(bytes, CONTENT_ADDRESSED_HINT + ImageFormats.extensionFor(ImageFormats.sniff(bytes))),
|
||||
)
|
||||
|
||||
private fun write(file: File?, bytes: ByteArray): Result<CachedDocument> {
|
||||
val target = file
|
||||
?: return Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
// 内容寻址的名字相同 ⇒ 字节相同:已经落过盘就不重写(同一张图重复打开不该反复写盘)。
|
||||
if (DocumentCache.isHit(target)) return Result.success(CachedDocument(target, bytes))
|
||||
return try {
|
||||
DocumentCache.write(target, bytes)
|
||||
Result.success(CachedDocument(target, bytes))
|
||||
} catch (_: Exception) {
|
||||
target.delete()
|
||||
Result.failure(DocumentCacheUnavailableException())
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 内容寻址名的扩展名载体:`localName` 只看扩展名,名字本身会被 sha256 换掉。 */
|
||||
const val CONTENT_ADDRESSED_HINT = "image"
|
||||
}
|
||||
}
|
||||
|
||||
/** 只服务免鉴权图片读侧的裸客户端;进程内一份,连接池与线程池复用。 */
|
||||
private val imageHttpClient: OkHttpClient by lazy { OkHttpClient() }
|
||||
|
||||
/**
|
||||
* GET 一个图片地址并读回字节(至多 [DocumentAttachments.MAX_FILE_BYTES])。
|
||||
*
|
||||
* 非 2xx 走 [ApiErrorMapper]——与所有 REST 仓库同一口径:服务端有话说就用它那句,没有就说不出
|
||||
* 的时候也不编。2xx 却读不出字节(0 字节 / 超过上限)同样判失败:**绝不**把一份空字节落进缓存,
|
||||
* 那会让下一次打开「命中」一份什么都没有的文件。
|
||||
*/
|
||||
private suspend fun httpGetImage(url: String): Result<ByteArray> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
imageHttpClient.newCall(Request.Builder().url(url).get().build()).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
CyreneException(ApiErrorMapper.fromHttp(response.code, response.body.string())),
|
||||
)
|
||||
}
|
||||
val bytes = readBounded(response.body.byteStream(), DocumentAttachments.MAX_FILE_BYTES)
|
||||
when {
|
||||
bytes == null -> Result.failure(ImageTooLargeException())
|
||||
bytes.isEmpty() -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
else -> Result.success(bytes)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
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.DocumentsApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
|
||||
/**
|
||||
* 一次成功上传的文档。
|
||||
*
|
||||
* **首次上传(`201`)与去重命中(`200`)返回的是同一个结果**——区别只在 [deduped]:
|
||||
* 服务端对「同一份字节 + 同一扩展名」回同一个 [name],调用方**必须当成功处理**,
|
||||
* 唯一的差别是可以据此少显示一次「已上传」的进度。
|
||||
*
|
||||
* [url] 是**相对**同源路径(`/api/v1/documents/<name>`),与图片上传的绝对 URL 不同:
|
||||
* 读侧要 Bearer,客户端真正要用的是 [name]。它留着只为排查与展示,**不要**当 `chat.image`
|
||||
* 那样的可直接取用地址。
|
||||
*/
|
||||
data class UploadedDocument(
|
||||
val name: String,
|
||||
val url: String,
|
||||
val mime: String,
|
||||
val size: Long,
|
||||
val sha256: String,
|
||||
val deduped: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 文档取数层(`POST /api/v1/documents` 上传、`GET /api/v1/documents/{name}` 取字节)。
|
||||
*
|
||||
* ## 与图片上传([UploadRepository])的三处刻意的不同
|
||||
* 1. **文件名参与裁决**:服务端把**白名单扩展名**与**字节嗅探**合取,两者都过才放行
|
||||
* (图片只看嗅探)。所以 [fileName] 必须原样带上真实扩展名,不能像图片那样自己编一个;
|
||||
* 2. **`200` 是成功**:同一份字节重复上传回 `200` + 同一个 name + `deduped:true`,
|
||||
* **不是错误**。这条由 [apiCall] 天然成立(它只看「2xx 且信封里有 data」);
|
||||
* 3. **`url` 不做绝对化校验**:文档的 url 本来就是相对路径(读侧要 Bearer),
|
||||
* 照搬图片那条「不是 http(s) 就判失败」的规则会把每一次成功上传都判成解析失败。
|
||||
*
|
||||
* ## 错误语义
|
||||
* 用 [apiCall]:状态码**不承载额外语义**(`413` / `415` / `400` / `404` 的机器可读区分
|
||||
* 全在 `error.code` 里),仓库只把它折进 `Result.failure`;调用方用
|
||||
* [top.yeij.cyrene.domain.chat.toDocumentFailure] 收敛成可分支的失败类型。
|
||||
*
|
||||
* ## 客户端不做的事(服务端才是裁决者)
|
||||
* **不做本地扩展名白名单、不做本地 20 MiB 上限**:白名单与上限都是服务端配置(上限是
|
||||
* `CHAT_DOCUMENT_MAX_BYTES`,可部署改),客户端复制一份必然漂移——把「本地觉得不行」当成
|
||||
* 拒绝,就会出现「服务端其实收得下、客户端却拦了」的静默更严。这里宁可让服务端回一个
|
||||
* 带真实上限的 `413` / `415`,让用户看到准确的理由。
|
||||
*/
|
||||
class DocumentRepository(
|
||||
private val api: DocumentsApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/** 上传一份文档([bytes] 与 [fileName] 必须来自同一个文件)。 */
|
||||
suspend fun upload(bytes: ByteArray, fileName: String): Result<UploadedDocument> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.upload(partOf(bytes, fileName)) }.fold(
|
||||
onSuccess = { data ->
|
||||
if (data.name.isBlank()) {
|
||||
// 201/200 却没有可用存储名 = 上传了但引用不上,判解析失败。
|
||||
// **绝不**退回「用本地文件名」——服务端已经把它丢掉了,引用一个不存在的名字
|
||||
// 只会让这一轮在服务端回 404。
|
||||
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
} else {
|
||||
Result.success(
|
||||
UploadedDocument(
|
||||
name = data.name,
|
||||
url = data.url,
|
||||
mime = data.mime,
|
||||
size = data.size,
|
||||
sha256 = data.sha256,
|
||||
deduped = data.deduped,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取回一份文档的原始字节。
|
||||
*
|
||||
* 响应体**不是信封**(就是文件本身),所以这里不用 [apiCall]:成功即读字节,
|
||||
* 失败才走 [ApiErrorMapper](`404 document_not_found` / `401`)。
|
||||
* `@Streaming` 的响应体必须由调用方关闭,[okhttp3.ResponseBody.use] 在这里收口。
|
||||
*/
|
||||
suspend fun download(name: String): Result<ByteArray> = withContext(dispatcher) {
|
||||
try {
|
||||
val response = api.download(name)
|
||||
val body = response.body()
|
||||
when {
|
||||
response.isSuccessful && body != null -> Result.success(body.use { it.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)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart 部件。
|
||||
*
|
||||
* **部件名固定 `file`**(服务端 `r.FormFile("file")`),**文件名必须给且要带扩展名**:
|
||||
* Go 只把带非空 `filename` 参数的部件当**文件**(不带的落进普通表单值,`FormFile` 就找不到它),
|
||||
* 而扩展名要参与服务端的白名单判定。[fileName] 为空时回落到
|
||||
* [FALLBACK_FILE_NAME]——只是为了不让一个「没有名字的部件」变成 `400 需要 multipart 字段 file`;
|
||||
* 真正的原因(用户到底送的是什么)只有调用方知道,**不要**靠这个兜底掩盖调用方的空名字。
|
||||
*
|
||||
* `Content-Type` 固定 `text/plain`:服务端**不采信**客户端声明(字节嗅探是唯一裁决),
|
||||
* 这里给一个诚实的默认值即可——文档白名单里放行的都是文本类。
|
||||
*/
|
||||
private fun partOf(bytes: ByteArray, fileName: String): MultipartBody.Part =
|
||||
MultipartBody.Part.createFormData(
|
||||
name = UPLOAD_FIELD_NAME,
|
||||
filename = fileName.takeIf { it.isNotBlank() } ?: FALLBACK_FILE_NAME,
|
||||
body = bytes.toRequestBody(MIME_TEXT.toMediaTypeOrNull()),
|
||||
)
|
||||
}
|
||||
|
||||
private const val UPLOAD_FIELD_NAME = "file"
|
||||
|
||||
private const val FALLBACK_FILE_NAME = "document.txt"
|
||||
|
||||
private const val MIME_TEXT = "text/plain"
|
||||
@@ -0,0 +1,123 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
|
||||
/**
|
||||
* 编辑内容的上限(契约 §1 行 6a:非空、≤ **4000 rune**)。
|
||||
*
|
||||
* 「rune」是**码点**而不是 `String.length`:后者数的是 UTF-16 码元,emoji 与绝大多数中文扩展字符
|
||||
* 会被算成两个。按 `length` 卡上限会在边界上**误伤**(客户端以为超了、不发;服务端其实收得下),
|
||||
* 也会**误放**(客户端以为没超、发出去被 400)。
|
||||
*/
|
||||
const val MESSAGE_EDIT_MAX_RUNES = 4000
|
||||
|
||||
/**
|
||||
* 一次编辑提交的准入判定(纯函数的结果,见 [validateMessageEdit])。
|
||||
*
|
||||
* 分成三档是因为**用户要做的动作不同**:空了要接着打字,超长了要删字,只有 [Accepted] 才该发请求。
|
||||
*/
|
||||
sealed interface MessageEditDraft {
|
||||
|
||||
/** 可以提交;[content] 就是要发给服务端的字符串(原样,**不 trim**——见 [validateMessageEdit])。 */
|
||||
data class Accepted(val content: String) : MessageEditDraft
|
||||
|
||||
/** 空 / 纯空白:服务端会回 `400`,本端**不发这个请求**。 */
|
||||
data object Empty : MessageEditDraft
|
||||
|
||||
/** 超过 [max] 个码点:服务端会回 `400`,本端同样不发。 */
|
||||
data class TooLong(val runes: Int, val max: Int = MESSAGE_EDIT_MAX_RUNES) : MessageEditDraft
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验一次编辑的内容(纯函数)。
|
||||
*
|
||||
* **不 trim**:服务端存的就是用户输入的那个字符串(契约只承诺「非空」),客户端替它去掉首尾空白
|
||||
* 等于悄悄改掉用户的消息——对代码块、Markdown 引用这类内容尤其明显。空白只用来判「空」。
|
||||
*
|
||||
* 计数按**码点**([runeCount]),与服务端 Go 的 `utf8.RuneCountInString` 同口径。
|
||||
*/
|
||||
fun validateMessageEdit(raw: String): MessageEditDraft {
|
||||
if (raw.isBlank()) return MessageEditDraft.Empty
|
||||
val runes = runeCount(raw)
|
||||
return if (runes > MESSAGE_EDIT_MAX_RUNES) {
|
||||
MessageEditDraft.TooLong(runes)
|
||||
} else {
|
||||
MessageEditDraft.Accepted(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 Unicode 码点计数:代理对(emoji、部分汉字)算一个,与服务端 rune 口径一致。 */
|
||||
fun runeCount(text: String): Int = text.codePointCount(0, text.length)
|
||||
|
||||
/**
|
||||
* 按 `id` **原位**改一条消息的 `content`(纯函数;编辑落地的唯一实现)。
|
||||
*
|
||||
* 三条刻意的取舍:
|
||||
* 1. **只改 `content`,不整条替换**。`message_edited` 帧里的 DTO 是完整消息,但它的 `thinking`
|
||||
* 恒为空(契约 v0.59 §4.4:实时帧一律剥离思维链)。整条替换会把本端已渲染的思维链擦掉,
|
||||
* 而契约明说这个操作**只改 content**——照它做,就不会有第二种解读。
|
||||
* 2. **找不到这个 `id` 就原样返回**,绝不插入。契约 §3.8 写得很直白:「找不到该 id 即忽略,
|
||||
* 避免把别的会话的消息注入当前列表」。别人的会话里那条消息不该在这里冒出来。
|
||||
* 3. **空内容是坏帧,不落**。服务端拒空内容(400),合法的编辑结果不可能为空;真收到空白就把
|
||||
* 气泡擦成空白,是拿坏帧毁掉一条好消息。宁可整帧忽略。
|
||||
*
|
||||
* **幂等**:[content] 与现值相同时返回**同一个实例**(Compose 的 key 稳定性靠它),因此
|
||||
* 「REST 响应先到、广播后到」(或反过来)连做两次与做一次完全等价——这就是双到达收敛的根据。
|
||||
* 不修改入参。
|
||||
*/
|
||||
fun applyMessageEdit(
|
||||
messages: List<HistoryMessageDto>,
|
||||
id: String,
|
||||
content: String,
|
||||
): List<HistoryMessageDto> {
|
||||
if (id.isBlank() || content.isBlank()) return messages
|
||||
var changed = false
|
||||
val updated = messages.map { message ->
|
||||
if (message.id != id || message.content == content) {
|
||||
message
|
||||
} else {
|
||||
changed = true
|
||||
message.copy(content = content)
|
||||
}
|
||||
}
|
||||
return if (changed) updated else messages
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 `id` 移除一条消息(纯函数;删除落地的唯一实现)。
|
||||
*
|
||||
* `id` 为空白即原样返回:`message_deleted` 的载荷是 `{id}`,坏帧(缺键)解出空串,
|
||||
* 而「删掉所有 id 为空的消息」不是任何人想要的结果。找不到该 `id` 同样原样返回(幂等)——
|
||||
* 「我们自己的 DELETE 回执 + 广播」两次到达、以及别的端删掉一条本端还没加载的消息,都走这里。
|
||||
*
|
||||
* 删除的**路由**由帧壳的 `sessionId` 负责(见
|
||||
* [top.yeij.cyrene.data.ws.isForSession]);`sessionId` 缺席时契约允许按**全局唯一**的 `id`
|
||||
* 兜底——那正是这个函数天然的行为:别的会话的列表里根本不存在这个 `id`。不修改入参。
|
||||
*/
|
||||
fun removeMessageById(messages: List<HistoryMessageDto>, id: String): List<HistoryMessageDto> {
|
||||
if (id.isBlank()) return messages
|
||||
// 没有这一条就原样返回**同一个实例**(幂等的可观察形式:连做两次与做一次完全等价,
|
||||
// 也不会白分配一个新列表)。
|
||||
if (messages.none { it.id == id }) return messages
|
||||
return messages.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条消息**能不能编辑**(产品口径,UI 与 VM 共用同一份判据)。
|
||||
*
|
||||
* 只放 `user`:assistant 的回复是她说过的话的历史记录,改它等于伪造她说过什么;而要「重发」
|
||||
* 一条说错的回复,正确的动作是删掉它再问一遍(契约也明确没有重发端点)。
|
||||
*
|
||||
* 用 `== "user"` 而不是「非 assistant」:`role` 在后端是**开放集合**,将来多出一个角色时
|
||||
* fail-closed(不改)比「默认都能改」安全。
|
||||
*/
|
||||
fun canEditMessage(message: HistoryMessageDto): Boolean = message.role == "user"
|
||||
|
||||
/**
|
||||
* 这条消息**能不能删除**(产品口径,UI 与 VM 共用同一份判据)。
|
||||
*
|
||||
* user 与 assistant 都放:删掉一句错答是常见需求,而且服务端对这个端点本就不分角色。
|
||||
* 未知 `role` 一律不放(fail-closed)——不认识的东西不主动去删。
|
||||
*/
|
||||
fun canDeleteMessage(message: HistoryMessageDto): Boolean =
|
||||
message.role == "user" || message.role == "assistant"
|
||||
@@ -1,15 +1,25 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.UpdateMessageRequest
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 会话消息分页获取层。服务端按倒序返回,这里统一转成升序的 [PagedMessages]。
|
||||
* 去重 / 合并请用 [mergeMessages]。
|
||||
* 会话消息层:分页 / 搜索 / **正向增量**读取,以及单条消息的**编辑 / 删除**(D3)。
|
||||
*
|
||||
* 三条读取出口共用同一个端点,只是给的查询参数不同:
|
||||
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
|
||||
* - [search]:`q` 会话内搜索(也是倒序);
|
||||
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6);[loadIncrement] 是它在
|
||||
* `after` 方向上的翻页版(一直追到尾巴,追不到就如实说 `complete = false`)。
|
||||
*
|
||||
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
|
||||
*/
|
||||
class MessageRepository(
|
||||
private val api: SessionsApi,
|
||||
@@ -41,21 +51,250 @@ class MessageRepository(
|
||||
platform: String? = null,
|
||||
): Result<PagedMessages> = loadPage(sessionId, limit, before = null, platform = platform)
|
||||
|
||||
/**
|
||||
* 会话内**内容搜索**(D2,契约 §1 行 6)。
|
||||
*
|
||||
* 与 [loadPage] 走的是**同一个端点、同一套分页语义**,唯一差别是会带上 `q`:
|
||||
* 服务端按大小写不敏感的 ASCII LIKE 在**本会话内**匹配 `content`(`%` / `_` / `\` 已转义为
|
||||
* 字面量),仍按 `createdAt` 倒序返回,`before` 仍是「上一页最旧那条的时间戳」。
|
||||
* 搜索绝不跨会话,所以这里没有 `platform` 参数——那是我方对多端来源的过滤,与搜索正交。
|
||||
*
|
||||
* **空查询不发请求**:契约里「空 `q` = 未传」意味着服务端会退化成普通分页,
|
||||
* 把整段历史当成搜索结果回给调用方——那比不发更像一个谎。所以空/纯空白一律返回一个
|
||||
* 空页(真实的「没有命中」由调用方在**已提交过查询**的前提下渲染)。
|
||||
*/
|
||||
suspend fun search(
|
||||
sessionId: String,
|
||||
query: String,
|
||||
limit: Int = DEFAULT_SEARCH_PAGE_SIZE,
|
||||
before: Long? = null,
|
||||
): Result<PagedMessages> = withContext(dispatcher) {
|
||||
val normalized = normalizeSearchQuery(query)
|
||||
if (normalized == null) return@withContext Result.success(EMPTY_PAGE)
|
||||
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
|
||||
apiCall { api.listMessages(sessionId, pageSize, before, platform = null, query = normalized) }
|
||||
.map { raw -> toPagedMessages(raw, pageSize) }
|
||||
}
|
||||
|
||||
/**
|
||||
* **正向增量**(A5,契约 §1 行 6):`GET /messages?after=<ms>`,返回 `created_at > after` 的
|
||||
* 消息,**升序**(与 [loadPage] 的倒序相反)。
|
||||
*
|
||||
* 它存在的唯一理由是补断线的洞:WS 重连只回放主会话**最近 50 条**,断线期间超过 50 条的
|
||||
* 消息用「再拉一页最新」是补不回来的——最新那一页会直接跳过中间那段。[after] 就是那个游标:
|
||||
* 传本端已看到的最后一条 `createdAt`,服务端从**最旧的一条开始**给。
|
||||
*
|
||||
* 分页推进的规矩(与 [loadPage] 不同,别照抄):下一页的游标是本页**最新**一条的
|
||||
* `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。
|
||||
*
|
||||
* 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)——
|
||||
* 落在页边界上的那一毫秒里剩下的行**再也追不回来**,因为服务端只给严格 `>`。调用方按 `id`
|
||||
* 去重,并且**不能把满页之后的一页(空页、不满页都一样)当作追到了尾巴**:要翻页又要把边界
|
||||
* 说清楚,用 [loadIncrement],不要自己拿这一页凑。
|
||||
*/
|
||||
suspend fun loadAfter(
|
||||
sessionId: String,
|
||||
after: Long,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
platform: String? = null,
|
||||
): Result<PagedMessages> = withContext(dispatcher) {
|
||||
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
|
||||
apiCall { api.listMessages(sessionId, pageSize, before = null, platform = platform, after = after) }
|
||||
.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**。
|
||||
*
|
||||
* 校验在**发请求之前**做([validateMessageEdit]):空 / 纯空白 / 超 4000 rune 的内容服务端
|
||||
* 一定回 `400`,明知会被拒还发一次只是白等一个来回。被拒时返回的失败**带得出原因**——
|
||||
* 调用方要把「为什么没提交」如实说出来,不能静默什么都不发生。
|
||||
*
|
||||
* 返回的是服务端那一份而不是本地改过的副本:调用方据此更新界面([applyMessageEdit]),
|
||||
* 与随后到达的 `message_edited` 广播收敛成同一次变更。
|
||||
*/
|
||||
suspend fun editMessage(messageId: String, content: String): Result<HistoryMessageDto> {
|
||||
val draft = when (val validation = validateMessageEdit(content)) {
|
||||
MessageEditDraft.Empty -> return Result.failure(
|
||||
CyreneException(CyreneError.Server("bad_request", "消息内容不能为空", 400)),
|
||||
)
|
||||
|
||||
is MessageEditDraft.TooLong -> return Result.failure(
|
||||
CyreneException(
|
||||
CyreneError.Server(
|
||||
"bad_request",
|
||||
"消息内容不能超过 ${validation.max} 个字符(当前 ${validation.runes})",
|
||||
400,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
is MessageEditDraft.Accepted -> validation.content
|
||||
}
|
||||
return withContext(dispatcher) {
|
||||
apiCall { api.updateMessage(messageId, UpdateMessageRequest(content = draft)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条消息(D3,契约 §1 行 6b):`DELETE /api/v1/messages/{id}`,成功返回被删消息的 `id`。
|
||||
*
|
||||
* 没有本地校验可做(只有一个路径参数),所以失败一律来自服务端:`404 not_found`(这条已经
|
||||
* 不在服务端了,常见于别的端先删掉)、`403`(权限)、`501`(没接 HistoryStore)、网络。
|
||||
* 调用方要把这些如实说出来——**静默失败会让人以为删掉了,而她下一条回复里那条还在**。
|
||||
*/
|
||||
suspend fun deleteMessage(messageId: String): Result<String> = withContext(dispatcher) {
|
||||
apiCall { api.deleteMessage(messageId) }.map { it.deleted }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PAGE_SIZE = 50
|
||||
const val MAX_PAGE_SIZE = 200
|
||||
|
||||
/** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */
|
||||
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,
|
||||
oldestCreatedAt = null,
|
||||
newestCreatedAt = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整搜索关键词:去首尾空白后仍为空 → null(调用方据此**不发请求**,见
|
||||
* [MessageRepository.search])。
|
||||
*
|
||||
* 只做 trim,**不改大小写、不转义 `%` / `_` / `\`**:服务端自己把这三个字符当字面量处理
|
||||
* (契约 §1 行 6),客户端再转一次会把 `\%` 这种输入搜成别的东西——两边都转义必然有一边是错的。
|
||||
* 大小写同理:匹配由服务端做 ASCII 不敏感比较,客户端保留用户原样输入。
|
||||
*/
|
||||
fun normalizeSearchQuery(raw: String): String? = raw.trim().takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* 原始响应 → [PagedMessages]:**无论服务端给的是倒序(`before` / `q`)还是升序(`after`)**,
|
||||
* 一律按 [MessageOrder] 排成升序。
|
||||
*
|
||||
* [hasMore] 两向同义:拿到满页 ⇒ 该方向**可能还有**(`before` 方向是更旧的、`after` 方向是更新的)——
|
||||
* 它是保守判断,具体能不能继续由调用方的游标决定。
|
||||
*/
|
||||
private fun toPagedMessages(
|
||||
rawNewestFirst: List<HistoryMessageDto>,
|
||||
raw: List<HistoryMessageDto>,
|
||||
pageSize: Int,
|
||||
): PagedMessages {
|
||||
val ascending = rawNewestFirst.sortedWith(MessageOrder)
|
||||
val ascending = raw.sortedWith(MessageOrder)
|
||||
return PagedMessages(
|
||||
messages = ascending,
|
||||
hasMore = rawNewestFirst.size >= pageSize,
|
||||
hasMore = raw.size >= pageSize,
|
||||
oldestCreatedAt = ascending.firstOrNull()?.createdAt,
|
||||
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,24 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.data.api.PersonaApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.UserPersonaDto
|
||||
|
||||
/**
|
||||
* 用户侧角色目录取数层(api-client §1 行 9),供「新建会话时选角色」用。
|
||||
*
|
||||
* 仓库不持有持久化状态:调用方失败时保留上一次拿到的列表——角色目录拿不到只会让弹层少几行,
|
||||
* 不该连带挡掉「用默认角色新建会话」这条降级路径。
|
||||
*/
|
||||
class PersonaRepository(
|
||||
private val api: PersonaApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
suspend fun personas(): Result<List<UserPersonaDto>> = withContext(dispatcher) {
|
||||
apiCall { api.personas() }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
@@ -30,9 +31,24 @@ class SessionRepository(
|
||||
apiCall { api.listSessions() }
|
||||
}
|
||||
|
||||
/** `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:<uuid>`)。 */
|
||||
suspend fun createSession(): Result<String> = withContext(dispatcher) {
|
||||
apiCall { api.createSession() }.map { it.sessionId }
|
||||
/**
|
||||
* `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:<uuid>`)。
|
||||
*
|
||||
* @param personaId null(或全空白)= **不绑角色**:走**不带 body** 的请求,线上与本端支持选角
|
||||
* 之前逐字节相同。空白按 null 处理是有依据的——契约里「省略 `personaId`」与「`personaId`
|
||||
* 为空」是同一件事(不绑,继承全局默认角色),没必要把 `{"personaId":""}` 发上去。
|
||||
* 非空 = 创建时绑定该角色,未知角色服务端回 `400 bad_request`。
|
||||
*
|
||||
* 角色**只在创建时**能定:`PATCH /sessions/{id}` 一旦收到 `personaId` 就回 `400`,要换角色只能新开。
|
||||
*/
|
||||
suspend fun createSession(personaId: String? = null): Result<String> = withContext(dispatcher) {
|
||||
val persona = personaId?.takeIf { it.isNotBlank() }
|
||||
val created = if (persona == null) {
|
||||
apiCall { api.createSession() }
|
||||
} else {
|
||||
apiCall { api.createSession(CreateSessionRequest(personaId = persona)) }
|
||||
}
|
||||
created.map { it.sessionId }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package top.yeij.cyrene.data.ws
|
||||
|
||||
import top.yeij.cyrene.core.auth.AuthRecovery
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.domain.chat.parallelWireValue
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -32,13 +33,11 @@ import kotlin.random.Random
|
||||
* Cyrene 实时通道客户端(`GET /ws/chat?token=<JWT>`,api-reference §6)。
|
||||
*
|
||||
* ## 用法
|
||||
* 进程内只有**一个**实例,由 `AppContainer.chatSocket` 持有;上层要收帧就取它,**不要自己 new**
|
||||
* (见下面「它不是某个屏幕的私有连接」):
|
||||
* ```
|
||||
* val socket = ChatSocket(baseUrlProvider = { cfg.baseUrl }, tokenProvider = { auth.token },
|
||||
* onAuthFailed = { auth.logout() })
|
||||
* socket.connect()
|
||||
* // 收集 socket.events,观察 socket.state
|
||||
* socket.sendChat(sessionId = "web", text = "你好", deviceId = deviceId)
|
||||
* socket.close()
|
||||
* val socket = container.chatSocket
|
||||
* // 收集 socket.events,观察 socket.state;发送用 socket.sendChat / socket.sendInterrupt
|
||||
* ```
|
||||
*
|
||||
* ## 会话归属
|
||||
@@ -46,6 +45,20 @@ import kotlin.random.Random
|
||||
* (见 [WsEvent.sessionId]),不补值。服务端广播**不按会话订阅**:同一条连接会收到**所有**
|
||||
* 会话的帧,因此收集 [events] 的上层**必须**按 [WsEvent.isForSession] 收敛到自己的会话。
|
||||
*
|
||||
* ## 它不是某个屏幕的私有连接:谁都不许关它
|
||||
* 本实例是**进程级共享**的一条连接——所有需要收帧的 ViewModel 订阅的是同一份 [events] / [state]。
|
||||
* 之所以能共享,正是靠上面那条广播语义:「一条连接收到全部帧」与「N 条连接各收全部帧」对上层是
|
||||
* **同一个语义**,每个 VM 收到的帧集合逐帧相同,所以共享改的只是「连接有几条」(连带每个连接
|
||||
* 各带一个 `OkHttpClient` 的成本),不是「帧怎么走」——各 VM 的会话过滤逻辑**一个字都不用动**。
|
||||
*
|
||||
* 由此推出两条硬约束:
|
||||
* 1. **[close] 只归应用级的那条登出 / 会话失效路径**(目前唯一调用点:`SessionsViewModel` 的
|
||||
* 登录态订阅,见 `AppContainer.chatSocket`)。任何 ViewModel 在 `onCleared` 里关它、页面退出时
|
||||
* 关它,都会把**别的屏幕**一起掐断——切会话会掐掉抽屉未读与管理页三帧,关管理页会掐掉主会话的
|
||||
* 流式。连接的生命周期是**进程 + 登录态**,不是屏幕。
|
||||
* 2. **[events] 的 `replay` 恒为 0**(理由见那里的 KDoc):共享之后,新建的 VM 会立刻订阅同一份流,
|
||||
* replay > 0 就等于把**历史**下行帧投给一个新来的订阅者。
|
||||
*
|
||||
* ## 心跳与重连
|
||||
* - OkHttp `pingInterval` 30s(服务端 ping 周期 54s / pong 等待 60s,客户端 30s 足够)。
|
||||
* - 服务端**没有 resume / 补发**:重连成功后由上层用新连接的 `history` 帧 + REST 补差去重。
|
||||
@@ -75,6 +88,16 @@ class ChatSocket(
|
||||
/** 连接状态机;见 [WsState]。 */
|
||||
val state: StateFlow<WsState> = _state.asStateFlow()
|
||||
|
||||
/**
|
||||
* 诊断用:此刻底层连接对象还在不在(`webSocket != null`)。
|
||||
*
|
||||
* 只读、不改变任何行为,也**不是**产品逻辑的输入(`send` 判的是 [state] 与这个对象本身)。
|
||||
* 它存在只为一件事:让「这一帧为什么没走 WS」在 logcat 里可读——[state] 与它**可能不一致**
|
||||
* (状态还是 `Connected` 而对象已经被 `finally` 清空、或者反过来),而那个不一致正是
|
||||
* `WsSendOutcome.Failed` 与 `Queued` 的分水岭。tag 见 `ChatViewModel.SEND_ROUTE_LOG_TAG`。
|
||||
*/
|
||||
val hasLiveSocket: Boolean get() = webSocket != null
|
||||
|
||||
private val _events = MutableSharedFlow<WsEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = EVENT_BUFFER,
|
||||
@@ -84,7 +107,10 @@ class ChatSocket(
|
||||
/**
|
||||
* 下行事件流。
|
||||
*
|
||||
* `replay = 0`:重放旧 delta 会让新订阅者重复渲染,会话状态应由 `history` / REST 重建。
|
||||
* **`replay` 恒为 0,这条不是调优而是必需**:重放旧 delta 会让新订阅者重复渲染,会话状态应由
|
||||
* `history` / REST 重建;而连接**进程内共享**之后,新建的 VM(切到一个没访问过的会话)会**立刻**
|
||||
* 订阅同一份流——replay > 0 等于把历史帧投给它,轻则重复渲染,重则会话串台。
|
||||
*
|
||||
* `extraBufferCapacity = 512 + DROP_OLDEST`:发送方是 OkHttp 读线程,不能阻塞;溢出时丢最旧的
|
||||
* 增量帧(服务端发送缓冲 32 本就会丢帧,`stream_end` / `history` 仍可兜底)。
|
||||
*/
|
||||
@@ -114,7 +140,12 @@ class ChatSocket(
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
/** 开始连接(幂等:已在连接 / 重连中时忽略)。 */
|
||||
/**
|
||||
* 开始连接。**幂等**:已经在连接 / 重连中([wantConnection] 已置位)时直接返回。
|
||||
*
|
||||
* 因为幂等,任何一个 VM 都可以在需要时调它做兜底;但**决定它该不该在**的只有登录态那条路径
|
||||
* (见类注释的硬约束 1),本方法只负责「接上」。
|
||||
*/
|
||||
fun connect() {
|
||||
if (wantConnection) return
|
||||
wantConnection = true
|
||||
@@ -123,7 +154,14 @@ class ChatSocket(
|
||||
sessionJob = ensureScope().launch { runConnectionLoop() }
|
||||
}
|
||||
|
||||
/** 主动断开并停掉重连;之后可再次 [connect]。 */
|
||||
/**
|
||||
* 主动断开并停掉重连;之后可再次 [connect]。
|
||||
*
|
||||
* ⚠️ 本实例是**进程级共享连接**,不是某个屏幕的私有连接:**只有应用级的登出 / 会话失效路径可以
|
||||
* 调它**(目前唯一调用点:`SessionsViewModel` 的登录态订阅)。ViewModel 的 `onCleared`、
|
||||
* 页面退出、切会话都**不许**调——那会把别的屏幕正在用的连接一起掐断。理由见类注释
|
||||
* 「它不是某个屏幕的私有连接:谁都不许关它」。
|
||||
*/
|
||||
fun close() {
|
||||
wantConnection = false
|
||||
val socket = webSocket
|
||||
@@ -139,19 +177,28 @@ class ChatSocket(
|
||||
/**
|
||||
* 发送 `chat`(api-reference §6.3)。
|
||||
* @param clientMsgId 幂等键(契约 v0.56 §3.13);重复到达服务端回 `chat_duplicate`,不落库。
|
||||
* @return 未连接或写失败返回 `false`(本层不做离线队列,由上层标红重试)。
|
||||
* @param parallel 要不要声明 `parallel:true`(忙时并行规范 §2.2)——**默认 `false` = 一个字节
|
||||
* 都不带该键**,线上帧与加入本参数之前**逐字节相同**。调用方只有一处:`ChatViewModel.deliver`,
|
||||
* 它按 App 偏好(`InterruptMode`)与「会话是否正忙」算出来(判据是纯函数 `parallelForSend`)。
|
||||
* @return 未连接或写失败返回 `false`(本层不做离线队列;上层按**这一刻的连接状态**分档处置:
|
||||
* 未就绪 ⇒ 记住这条、连接恢复后按序补发,连接就绪却仍发不出去 ⇒ 回滚 + 诚实文案——
|
||||
* 判据是 `ChatViewModel.deliver` 里的纯函数 `wsSendOutcome`)。
|
||||
*/
|
||||
fun sendChat(
|
||||
sessionId: String?,
|
||||
text: String,
|
||||
deviceId: String?,
|
||||
clientMsgId: String? = null,
|
||||
parallel: Boolean = false,
|
||||
): Boolean = send(
|
||||
WsOutbound.Chat(
|
||||
text = text,
|
||||
sessionId = sessionId,
|
||||
deviceId = deviceId,
|
||||
clientMsgId = clientMsgId,
|
||||
// 直接传 `false` 会序列化成 `"parallel":false`(`WsOutbound.Chat` 只在 `null` 时省略该键),
|
||||
// 那不是「未声明」而是**显式否认**——默认两档必须逐字节不变 ⇒ 只有 true 才带上该键。
|
||||
parallel = parallelWireValue(parallel),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -162,12 +209,51 @@ class ChatSocket(
|
||||
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
|
||||
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
|
||||
*
|
||||
* **今天没有任何调用方**:聊天的「打断」触发点属聊天区那一轮,本 API 先把传输面备好——
|
||||
* 调用方从 App 偏好(`InterruptMode`)取 `scope` 后传进来即可。
|
||||
* **调用方有两处,两处都只发 `"current"`**(本端从不发 `all`,见 `devdocs/02` 的 A2 补记):
|
||||
* 1. `ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前**按 App 偏好
|
||||
* (`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数
|
||||
* `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope`
|
||||
* 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。
|
||||
* 2. `SessionsViewModel.stopGenerating(sessionId)`——抽屉里那一行的「停止」(W52B):它是一个
|
||||
* **独立的动作**(不读偏好),停的是**指定会话**正在跑的那一轮。`WsOutbound.Interrupt`
|
||||
* 本来就带 `sessionId`,所以「停某一个会话」这条既有出口表达得了,不需要新帧。
|
||||
*
|
||||
* 返回 false 只有一种原因(WS 未连接):两处调用方都**静默**,理由见各自的 KDoc。
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
@@ -187,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,
|
||||
@@ -224,12 +236,93 @@ sealed class WsEvent {
|
||||
override val type: String get() = "stream_end"
|
||||
}
|
||||
|
||||
/**
|
||||
* **新帧 `turn_begin`**(忙时并行规范 §2.4):编排器**每次启动一路**时发一次,
|
||||
* 把「服务端刚起的这一路 ↔ 它归属的用户消息 id」钉死。
|
||||
*
|
||||
* 客户端靠它(而不是靠猜)做三件事:① 按 `messageIds` 把那些气泡置为**灰态**(④);
|
||||
* ② 建立「消息 ↔ 回合」绑定,好在收口时用该 `turnId` 的 `turn.end` 配对;③ 按 `turnId` 分桶。
|
||||
*
|
||||
* [kind] ∈ `"turn"` \| `"parallel"`:服务端**实际**如何起这条路(新回合 / 续跑 = `"turn"`,
|
||||
* 声明并行且照准 = `"parallel"`)。规范 §2.4 说状态机不必依赖它,本端照办:[messageIds] 仍是
|
||||
* 六态的唯一判据。但**「这一路是不是并行路」只有它说了算**——[leg] 是**会话内第几条路**
|
||||
* (服务端对每条新路都自增,普通串行回合照样占号),拿它当并行的证据会挂出并不存在的
|
||||
* 「并行路 N」。所以本端把它带进绑定(`TurnBinding.parallel`),只喂给路小标那一处;
|
||||
* 缺席(老服务端整键不给)时为空串 ⇒ 认不出来 ⇒ 不挂角标。
|
||||
*
|
||||
* [leg] 是该路的会话内序号(1 基,`1` = 第一路),注意它**不是**「并行组里的第几路」。
|
||||
* 小标文案取它,但是否挂标取 [kind]。
|
||||
* [activeTurns] 是**本次启动后**该会话的在途路数。
|
||||
*
|
||||
* 帧壳的 `sessionId` 是权威归属(`data` 内不重复,规范 §2.4);本端仍按既有口径
|
||||
* 「`data` 有值就优先,否则用帧壳」解析,两边都不给时按「无法判定」放行(见 [isForSession])。
|
||||
*
|
||||
* **帧壳 `turnId` 是这一路的唯一权威标识**(规范附录明写:它必须与该路全部流帧同值)。
|
||||
* 它缺席(`null`)时本端**不做任何绑定**——没有 key 的绑定永远收不了口,那不是绑定,
|
||||
* 是一个永远灰着的气泡;此时整帧只消费 [activeTurns],其余部分与今天一致。
|
||||
*/
|
||||
data class TurnBegin(
|
||||
override val sessionId: String,
|
||||
val kind: String = "",
|
||||
val messageIds: List<String> = emptyList(),
|
||||
val leg: Int = 0,
|
||||
val activeTurns: Int = 0,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "turn_begin"
|
||||
}
|
||||
|
||||
/**
|
||||
* **`turn.end`**:一路的**整轮**结束(覆盖最终答案 / 工具循环终止 / 预算耗尽 / 出错四种出口)。
|
||||
*
|
||||
* 它是灰态(④)收口的**唯一权威事件**(规范 §1.2):拿帧壳的 [turnId] 与消息绑定的 `turnId`
|
||||
* 比对,相等即收口。规范特意说明为什么不能用正文到齐来收口——一轮里有多个正文段(`message.end`
|
||||
* 只结束**一段**),只有 `turn.end` 结束**整轮**。
|
||||
*
|
||||
* 它由 loop 发出、经网关的流式广播路径下发(`broadcastStream` 会给它打上 `sessionId` + `turnId`),
|
||||
* 因此**一直存在于线上**,只是本端此前把它当未知帧丢掉了(`type` 不在 `when` 里 ⇒ `parse` 回
|
||||
* `null`)。补上它的解析不改变任何既有行为(此前没有任何消费者)。
|
||||
*/
|
||||
data class TurnEnd(
|
||||
override val sessionId: String? = null,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "turn.end"
|
||||
}
|
||||
|
||||
/**
|
||||
* `steer_ack`:这条消息**已被本回合吃进**(已从 pending 移除、尚未被正面回答)⇒ 灰态(④)。
|
||||
*
|
||||
* 契约 v0.57 起 [messageId] / [atStep] 就在,**一字未变**;忙时并行规范 §2.5 **只新增帧壳
|
||||
* `turnId`**——并行下它是**必需**字段:没有它就不知道这条消息被**哪一路**吃进,灰态无法与
|
||||
* 正确的 `turn.end` 配对(这正是「两路并行」相对「单路」多出来的唯一一件事)。
|
||||
*
|
||||
* 帧壳 `turnId` 缺席时本端**不消费这一帧**(老服务端就是这样)——那样绑出来的绑定永远收不了口,
|
||||
* 宁可保持今天的样子(什么都不发生),也不留下一个永远灰着的气泡。
|
||||
*/
|
||||
data class SteerAck(
|
||||
override val sessionId: String,
|
||||
val messageId: String,
|
||||
val atStep: Int,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "steer_ack"
|
||||
}
|
||||
|
||||
/** 会话进入忙碌态。`mode` ∈ `idle` / `thinking` / `executing` / `subagent`。 */
|
||||
data class SessionBusy(
|
||||
override val sessionId: String,
|
||||
val mode: String,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
/**
|
||||
* 该帧发出时的**在途路数**(忙时并行规范 §1.5 / §2.6,**纯增量**)。
|
||||
* `null` = 服务端没给这个键(v0.65 之前)⇒ 客户端**不动**任何计数,行为与今天逐字相同。
|
||||
*/
|
||||
val activeTurns: Int? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "session_busy"
|
||||
}
|
||||
@@ -241,6 +334,12 @@ sealed class WsEvent {
|
||||
val queueDepth: Int,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
/**
|
||||
* 入队的原因码(忙时并行规范 §2.6,**纯增量**)。已知取值 `"parallel_limit"`
|
||||
* (声明了并行但额度已满、已落回排队)。其它情况**整键缺席**(`null`)——
|
||||
* 服务端明写「绝不伪造」,所以这里也**绝不**给它一个默认值。
|
||||
*/
|
||||
val reason: String? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "pending_ack"
|
||||
}
|
||||
@@ -252,36 +351,76 @@ sealed class WsEvent {
|
||||
val queueDepth: Int,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
/** 见 [SessionBusy.activeTurns](忙时并行规范 §2.6)。 */
|
||||
val activeTurns: Int? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "pending_drain"
|
||||
}
|
||||
|
||||
/** 会话回到空闲、队列清空。 */
|
||||
/**
|
||||
* 会话回到空闲、队列清空。
|
||||
*
|
||||
* ⚠️ **时点在并行时收紧**(规范 §1.5):它现在只在**所有在途路都收尾**且 pending 空时才发;
|
||||
* 单路时与今天逐字相同。所以「`session_idle` 到了 = 这个会话真的闲了」这条既有推论仍然成立,
|
||||
* 客户端**不必**改任何既有逻辑。
|
||||
*/
|
||||
data class SessionIdle(
|
||||
override val sessionId: String,
|
||||
val mode: String,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
/** 见 [SessionBusy.activeTurns](忙时并行规范 §2.6)。 */
|
||||
val activeTurns: Int? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "session_idle"
|
||||
}
|
||||
|
||||
/** 用户打断:当前任务取消、pending 清空,`dropped` 为丢弃条数。 */
|
||||
/**
|
||||
* 用户打断:当前任务取消。
|
||||
*
|
||||
* [dropped] **只在我们发了 `scope:"all"` 时才可能非零**(那档会清空待处理队列,`dropped` = 被清掉的
|
||||
* 条数,且 [mode] 为 `idle`);**本端从不发 `all`** —— 「追加队列」档一个 `interrupt` 都不发,
|
||||
* 「打断」档发的是 `scope:"current"`(只取消在跑回合、**保留**待处理队列)⇒ 在客户端的全部现实路径里
|
||||
* [dropped] **恒为 0**(契约 v0.56 明文)。所以「已打断,丢弃 N 条」那句话在本端是**不可达**的,
|
||||
* 别照着它去实现一个用不上的计数(见 `devdocs/03` §4 的同一条更正)。
|
||||
*/
|
||||
data class SessionInterrupted(
|
||||
override val sessionId: String,
|
||||
val mode: String,
|
||||
val dropped: Int,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
/** 见 [SessionBusy.activeTurns](忙时并行规范 §2.6)。 */
|
||||
val activeTurns: Int? = null,
|
||||
/**
|
||||
* 本次**真的取消了几路**(忙时并行规范 §2.6,**纯增量**)。
|
||||
* 单路时它只会是 `0` 或 `1`;`null` = 服务端没给这个键。
|
||||
*/
|
||||
val cancelledTurns: Int? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "session_interrupted"
|
||||
}
|
||||
|
||||
/** 单会话 pending 达上限(`max`,默认 5),拒绝入队。 */
|
||||
/**
|
||||
* 单会话 pending 达上限(`max`,默认 5),拒绝入队。
|
||||
*
|
||||
* 契约 v0.59(协同 `G5`)在载荷上增**两个可选键**,两个都可能**整键缺席**:
|
||||
* - [clientMsgId] = **被拒那条消息**的客户端幂等键(那条消息没带键时缺席,**绝不伪造**);
|
||||
* - [messageId] = 该消息**此前已落库 / 已认领**时的 id(未被认领时缺席)。
|
||||
*
|
||||
* 服务端**绝不在这里新铸 id**:队列满的判定发生在幂等键**认领之前**,代铸一个 id 会让客户端
|
||||
* 拿同一个 `clientMsgId` 重试时被判成「重复到达」而永远进不来。于是 [messageId] 非空恰好
|
||||
* 意味着「**这条其实早就进去了**」(它是重发撞上满队列)——消费方必须收编,**不是**回滚。
|
||||
*
|
||||
* 该帧是**广播**(每条已认证连接都会收到,含 `viewer`),所以 [clientMsgId] 缺席时**无法
|
||||
* 判定被拒的是不是本端那条气泡**;判定规则见 `ChatViewModel.queueFullOutcome`。
|
||||
*/
|
||||
data class PendingFull(
|
||||
override val sessionId: String,
|
||||
val mode: String,
|
||||
val max: Int,
|
||||
val clientMsgId: String? = null,
|
||||
val messageId: String? = null,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
) : WsEvent() {
|
||||
@@ -329,9 +468,21 @@ sealed class WsEvent {
|
||||
override val type: String get() = "session_read"
|
||||
}
|
||||
|
||||
/** 主会话(`web` / `web:<uuid>`)消息落库后广播。 */
|
||||
/**
|
||||
* 主会话(`web` / `web:<uuid>`)消息落库后广播。
|
||||
*
|
||||
* [proactive] = **这句话是她主动说的**(v0.72 起,服务端走 IM 桥那条路由时会补发这一帧,
|
||||
* 并在帧上打这个判据)。它此前被本端**丢掉**了:`data` 只解 `message`,而生产 `Json` 是
|
||||
* `ignoreUnknownKeys = true` ⇒ 这个键既没被解、也不会报错,于是「她主动说的话走了落库这条路」
|
||||
* 这件事在客户端**完全不可见**(W53N 的通知判据正是靠它)。
|
||||
*
|
||||
* 判据的两种落点都接受(见 [parse]):`data.proactive` 与帧壳顶层的 `proactive` 取**或**。
|
||||
* 契约只说「帧顶带它」,没说清是帧壳还是载荷,而**猜错就是整个功能静默失效**——所以两处都读。
|
||||
* 缺席(`false`)= 普通消息,绝不弹通知(主人只批了 proactive 这一档)。
|
||||
*/
|
||||
data class MessagePersisted(
|
||||
val message: HistoryMessageDto?,
|
||||
val proactive: Boolean = false,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
override val sessionId: String? = null,
|
||||
@@ -349,12 +500,130 @@ sealed class WsEvent {
|
||||
override val type: String get() = "channel_persisted"
|
||||
}
|
||||
|
||||
/** 语音合成回复(定向回请求连接)。 */
|
||||
/**
|
||||
* 一条消息被编辑(D3,契约 §3.8,api-client 上行端点见 §1 行 6a)。
|
||||
*
|
||||
* `data` 是**编辑后的完整消息 DTO**(与 [MessagePersisted] 同形,含 `id` / `content` /
|
||||
* `sessionId` / `createdAt`,另带 `role` / `mode?` / `mood?` / `image?` / `platform?` /
|
||||
* `documents`),`thinking` 已按契约剥离。
|
||||
*
|
||||
* 三处要害:
|
||||
* 1. 它是**全连接广播**(hub 没有按会话订阅),所以会话归属仍要走 [isForSession];
|
||||
* 帧壳通常带 `sessionId`,缺席时用 `data` 里消息自己的 `sessionId` 兜底;
|
||||
* 2. 消费方**按 `id` 原位改 `content`**,找不到忽略(绝不插入——契约 §3.8);
|
||||
* 3. **本端自己发起的编辑也会收到这一帧**(`PATCH` 成功后服务端照旧广播),于是它与
|
||||
* REST 响应是同一件事的两种到达方式,必须收敛成一次(见
|
||||
* [top.yeij.cyrene.data.repository.applyMessageEdit] 的幂等性)。
|
||||
*
|
||||
* [message] 为 `null` = 帧里没有可用载荷;此时**整帧跳过**(没有 id 也没有 content,
|
||||
* 无事可做),而不是编一条空消息出来。
|
||||
*/
|
||||
data class MessageEdited(
|
||||
val message: HistoryMessageDto?,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
override val sessionId: String? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "message_edited"
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条消息被删除(D3,契约 §3.8):`{type:"message_deleted", sessionId, data:{id}}`。
|
||||
*
|
||||
* 载荷**只有** `id`:契约刻意不给整条消息(客户端要做的只是把它拿掉)。会话归属来自帧壳
|
||||
* (后端在删除前读一次该消息所属会话填进去);[sessionId] 缺席时按**全局唯一**的 `id`
|
||||
* 兜底——逐桶搜索只会命中它自己那条,这正是契约允许的兜底方式。
|
||||
*
|
||||
* [id] 空白(坏帧)时上层什么都不做(见 [top.yeij.cyrene.data.repository.removeMessageById]):
|
||||
* 「删掉所有 id 为空的消息」不是任何人想要的结果。
|
||||
*/
|
||||
data class MessageDeleted(
|
||||
val id: String,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
override val sessionId: String? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "message_deleted"
|
||||
}
|
||||
|
||||
/**
|
||||
* 指标快照(契约 v0.55 §3.12「可观测实时推送」)。
|
||||
*
|
||||
* 载荷就是 `AdminMetrics` **本体、扁平**,与 `GET /admin/observability/metrics` 同形,
|
||||
* 因此消费方应当**整份替换**而不是累加:服务端只在快照与「上次发出的」不同时才发帧
|
||||
* (序列化指纹比较,键排序确定)。
|
||||
*
|
||||
* [metrics] 为 `null` = 帧里没有可用的载荷对象。这时**必须整帧跳过**:
|
||||
* 回落成默认值会得到一份「全零快照」,把界面上的真实指标擦成 0。
|
||||
*
|
||||
* 帧壳**不带** `sessionId` / `turnId` / `seq`——它不属任何会话(订阅只跟凭据绑定)。
|
||||
*/
|
||||
data class ObservabilityMetrics(
|
||||
val metrics: AdminMetricsDto?,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
override val sessionId: String? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "observability_metrics"
|
||||
}
|
||||
|
||||
/**
|
||||
* 新完成的 trace span(契约 v0.55 §3.12)。
|
||||
*
|
||||
* 载荷 `{spans:[…]}` **最新在前**,与 `GET /admin/observability/trace` 同形,
|
||||
* 单帧上限 100 条;触发是「订阅之后**新完成**的 span」(订阅锚点 = 当时的 trace 写入序号),
|
||||
* 所以它是**增量**——历史由页面那次 REST 引导读取提供,这里只负责往后接。
|
||||
*/
|
||||
data class ObservabilityTrace(
|
||||
val spans: List<AdminTraceSpanDto>,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
override val sessionId: String? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "observability_trace"
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增日志(契约 v0.55 §3.12)。
|
||||
*
|
||||
* 载荷 `{entries:[…]}` **最新在前**(与 `GET /admin/observability/logs` 同形,共用
|
||||
* [AdminLogEntryDto]),单帧上限 200 条;触发是「订阅之后**新增**的日志」(订阅锚点 = 当时
|
||||
* 最新的条目时间),所以它同样是**增量**。服务端的 `?level=` 分级过滤对推送不适用
|
||||
* (帧是广播的,hub 不按连接分级),分级选择由客户端本地做。
|
||||
*/
|
||||
data class ObservabilityLog(
|
||||
val entries: List<AdminLogEntryDto>,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
override val sessionId: String? = null,
|
||||
) : 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,
|
||||
@@ -362,7 +631,21 @@ sealed class WsEvent {
|
||||
override val type: String get() = "voice_output"
|
||||
}
|
||||
|
||||
/** 自主主动推送(仅 owner/admin 连接收到)。 */
|
||||
/**
|
||||
* 自主主动推送(仅 owner/admin 连接收到)。
|
||||
*
|
||||
* v0.72 起她主动说的话**同时是主会话历史里的真消息**(`web` / `assistant` / `turn_id` 空),
|
||||
* 载荷因此多带两个键:[id] / [createdAt] = 那条**落库行**的权威 id 与时间戳。
|
||||
* 它与 [messageId](IM 侧的消息号)是**两个不同的 id**,拿后者当去重键永远对不上历史行。
|
||||
* 落库失败时这两个键**一起缺席**——那是「这条只有实时帧、没有历史行」的唯一标志,
|
||||
* 消费方据此退化(见 [top.yeij.cyrene.ui.chat.proactiveIngestFor])。
|
||||
*
|
||||
* [timestamp] 是服务端侧这条统一消息**自身**的时间。线上是 Go `time.Time` ⇒ **RFC3339 字符串**
|
||||
* (数字形状也容忍);它只作诊断,排序与去重一律用 [createdAt](Unix 毫秒)。
|
||||
*
|
||||
* ⚠️ 帧壳**不带** `sessionId`(服务端构造这一帧时没设它,且该键是 `omitempty`)——所以
|
||||
* [isForSession] 会把这帧**放行给每一个会话的 VM**,归属只能由消费方自己钉死(它属于主会话)。
|
||||
*/
|
||||
data class ProactiveMessage(
|
||||
val platform: String?,
|
||||
val channelId: String?,
|
||||
@@ -370,8 +653,10 @@ sealed class WsEvent {
|
||||
val content: String,
|
||||
val contentType: String?,
|
||||
val sender: String?,
|
||||
val timestamp: Long?,
|
||||
val timestamp: String?,
|
||||
val messageId: String?,
|
||||
val id: String? = null,
|
||||
val createdAt: Long? = null,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
override val sessionId: String? = null,
|
||||
@@ -379,9 +664,27 @@ sealed class WsEvent {
|
||||
override val type: String get() = "proactive_message"
|
||||
}
|
||||
|
||||
/** 服务端错误(非法 JSON / 未知类型 / 语音·子代理·任务错误等)。 */
|
||||
/**
|
||||
* 服务端错误帧。
|
||||
*
|
||||
* 它是**定向**的(只回请求那条连接,不广播),因此**不进角色门**;帧壳通常**不带**
|
||||
* `sessionId`(会话归属在 `data.sessionId` 里,见 [resolveSessionId])、也不带
|
||||
* `turnId` / `seq`——它不属任何轮次。
|
||||
*
|
||||
* [code] 是**机器可读**的业务码,取值是**开放集合**(契约 v0.59/G5 起至少含 `chat_busy`,
|
||||
* 与 REST 的 `409 chat_busy` **同码**,客户端一个 code 覆盖两条入口;另有 `bad_request` 等)。
|
||||
* 未知码**原样保留**:不抛、不让整帧消失,只是客户端不做语义处置——[message] 照常透出。
|
||||
*
|
||||
* [clientMsgId] / [messageId] 只在 `code == "chat_busy"`(队列满的定向伴生帧,
|
||||
* `{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`)
|
||||
* 时有意义:[clientMsgId] = 被拒那条消息的幂等键,[messageId] 仅当它此前已落库 / 已认领时
|
||||
* 才出现(与 [PendingFull] 同一份键,语义也相同)。其余错误码下它们缺席。
|
||||
*/
|
||||
data class Error(
|
||||
val message: String,
|
||||
val code: String? = null,
|
||||
val clientMsgId: String? = null,
|
||||
val messageId: String? = null,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
override val sessionId: String? = null,
|
||||
@@ -403,7 +706,16 @@ sealed class WsEvent {
|
||||
val seq = root["seq"].asStringOrNull()?.toLongOrNull()
|
||||
val sessionId = root["sessionId"].asStringOrNull()
|
||||
val data = root["data"]?.takeIf { it !is JsonNull }
|
||||
return runCatching { dispatch(type, turnId, seq, sessionId, data) }.getOrNull()
|
||||
val event = runCatching { dispatch(type, turnId, seq, sessionId, data) }.getOrNull()
|
||||
?: return null
|
||||
// 帧顶那一位 `proactive`(见 [MessagePersisted])。这一层只对落库帧有意义,载荷里的那一份
|
||||
// 由 [PersistedData] 解出来,两边**取或**:契约写的是「帧顶带它」而没说是帧壳还是载荷,
|
||||
// 而猜错的方向是「功能静默失效」,所以两处都认。
|
||||
return if (event is MessagePersisted && root.frameProactive()) {
|
||||
event.copy(proactive = true)
|
||||
} else {
|
||||
event
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatch(
|
||||
@@ -413,8 +725,41 @@ sealed class WsEvent {
|
||||
sessionId: String?,
|
||||
data: JsonElement?,
|
||||
): WsEvent? = dispatchStreaming(type, turnId, seq, sessionId, data)
|
||||
?: dispatchObservability(type, turnId, seq, sessionId, data)
|
||||
?: dispatchSession(type, turnId, seq, sessionId, data)
|
||||
|
||||
/**
|
||||
* 可观测实时推送三帧(契约 v0.55 §3.12)。
|
||||
*
|
||||
* `data` **必须是 JSON 对象**才认:这三帧的载荷是它们的全部内容,缺席时没有任何可用的东西。
|
||||
* 尤其 metrics——若回落成默认值,消费方会拿一份全零快照覆盖界面上的真实指标。返回 `null`
|
||||
* 让整帧按坏帧被安全忽略(与未知 `type` 同一条出口,绝不抛)。
|
||||
*/
|
||||
private fun dispatchObservability(
|
||||
type: String,
|
||||
turnId: String?,
|
||||
seq: Long?,
|
||||
sessionId: String?,
|
||||
data: JsonElement?,
|
||||
): WsEvent? {
|
||||
val payload = data as? JsonObject ?: return null
|
||||
return when (type) {
|
||||
"observability_metrics" -> ObservabilityMetrics(
|
||||
decode<AdminMetricsDto>(payload), turnId, seq, sessionId,
|
||||
)
|
||||
|
||||
"observability_trace" -> ObservabilityTrace(
|
||||
decode<TracePushData>(payload).spans, turnId, seq, sessionId,
|
||||
)
|
||||
|
||||
"observability_log" -> ObservabilityLog(
|
||||
decode<LogPushData>(payload).entries, turnId, seq, sessionId,
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchStreaming(
|
||||
type: String,
|
||||
turnId: String?,
|
||||
@@ -442,10 +787,14 @@ 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) }
|
||||
// `turn.end` 的载荷是**空的**(后端 `streamEventData` 里 `StreamKindTurnEnd` 直接返回
|
||||
// `nil`),全部信息都在帧壳上(`sessionId` + `turnId`)——所以这里不 decode payload,
|
||||
// 也**不**因为 `data` 缺席而把它当坏帧(那正是「灰态收不了口」的成因)。
|
||||
"turn.end" -> TurnEnd(sessionId, turnId, seq)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -457,17 +806,74 @@ sealed class WsEvent {
|
||||
data: JsonElement?,
|
||||
): WsEvent? = when (type) {
|
||||
"session_busy" -> decode<SessionModeData>(data)
|
||||
.let { SessionBusy(resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq) }
|
||||
.let {
|
||||
SessionBusy(
|
||||
resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq, it.activeTurns,
|
||||
)
|
||||
}
|
||||
"pending_ack" -> decode<PendingData>(data)
|
||||
.let { PendingAck(resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq) }
|
||||
.let {
|
||||
PendingAck(
|
||||
resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq, it.reason,
|
||||
)
|
||||
}
|
||||
"pending_drain" -> decode<PendingData>(data)
|
||||
.let { PendingDrain(resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq) }
|
||||
.let {
|
||||
PendingDrain(
|
||||
resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq, it.activeTurns,
|
||||
)
|
||||
}
|
||||
"session_idle" -> decode<SessionModeData>(data)
|
||||
.let { SessionIdle(resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq) }
|
||||
.let {
|
||||
SessionIdle(
|
||||
resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq, it.activeTurns,
|
||||
)
|
||||
}
|
||||
"session_interrupted" -> decode<InterruptedData>(data)
|
||||
.let { SessionInterrupted(resolveSessionId(sessionId, it.sessionId), it.mode, it.dropped, turnId, seq) }
|
||||
"pending_full" -> decode<PendingFullData>(data)
|
||||
.let { PendingFull(resolveSessionId(sessionId, it.sessionId), it.mode, it.max, turnId, seq) }
|
||||
.let {
|
||||
SessionInterrupted(
|
||||
resolveSessionId(sessionId, it.sessionId),
|
||||
it.mode,
|
||||
it.dropped,
|
||||
turnId,
|
||||
seq,
|
||||
it.activeTurns,
|
||||
it.cancelledTurns,
|
||||
)
|
||||
}
|
||||
// 新帧 `turn_begin`(忙时并行规范 §2.4):帧壳带 sessionId + turnId,载荷给
|
||||
// kind / messageIds / leg / activeTurns(**不含** sessionId)。
|
||||
"turn_begin" -> decode<TurnBeginData>(data)
|
||||
.let {
|
||||
TurnBegin(
|
||||
resolveSessionId(sessionId, it.sessionId),
|
||||
it.kind,
|
||||
it.messageIds.filter(String::isNotBlank),
|
||||
it.leg,
|
||||
it.activeTurns,
|
||||
turnId,
|
||||
seq,
|
||||
)
|
||||
}
|
||||
// `steer_ack`(v0.57 形状 + 忙时并行规范 §2.5 的帧壳 turnId):载荷只有
|
||||
// messageId / atStep;会话归属两边都可能有(取有值者)。
|
||||
"steer_ack" -> decode<SteerAckData>(data)
|
||||
.let {
|
||||
SteerAck(
|
||||
resolveSessionId(sessionId, it.sessionId), it.messageId, it.atStep, turnId, seq,
|
||||
)
|
||||
}
|
||||
"pending_full" -> decode<PendingFullData>(data).let {
|
||||
PendingFull(
|
||||
resolveSessionId(sessionId, it.sessionId),
|
||||
it.mode,
|
||||
it.max,
|
||||
it.clientMsgId,
|
||||
it.messageId,
|
||||
turnId,
|
||||
seq,
|
||||
)
|
||||
}
|
||||
// 幂等回执(v0.56 §3.13):帧壳带 sessionId,data 内也带一份(两者取有值者)。
|
||||
"chat_duplicate" -> decode<ChatDuplicateData>(data)
|
||||
.let {
|
||||
@@ -482,19 +888,61 @@ sealed class WsEvent {
|
||||
// 已读游标(D4 §3.9):会话归属只在帧壳上(data 只有 readAt)。
|
||||
"session_read" -> decode<SessionReadData>(data)
|
||||
.let { SessionRead(resolveSessionId(sessionId, ""), it.readAt, turnId, seq) }
|
||||
"message_persisted" -> decode<PersistedData>(data)
|
||||
.let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
|
||||
"message_persisted" -> decode<PersistedData>(data).let {
|
||||
MessagePersisted(
|
||||
message = it.message,
|
||||
proactive = it.proactive.asTrue(),
|
||||
turnId = turnId,
|
||||
seq = seq,
|
||||
sessionId = sessionId ?: it.message?.sessionId,
|
||||
)
|
||||
}
|
||||
"channel_persisted" -> decode<PersistedData>(data)
|
||||
.let { ChannelPersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
|
||||
"voice_output" -> decode<VoiceOutputData>(data)
|
||||
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
|
||||
// 消息编辑(D3 §3.8):`data` **就是**编辑后的完整消息 DTO——**没有** `message`
|
||||
// 包一层(这与 message_persisted 的 `data:{message:{…}}` 形状**不同**,照抄会解出 null)。
|
||||
// 坏帧(缺 data / 结构不对)解出 null:事件照旧成形,上层按「没有可用载荷」整帧跳过。
|
||||
"message_edited" -> decodeOrNull<HistoryMessageDto>(data).let {
|
||||
MessageEdited(it, turnId, seq, sessionId ?: it?.sessionId?.takeIf(String::isNotBlank))
|
||||
}
|
||||
// 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。
|
||||
"message_deleted" -> decode<DeletedData>(data)
|
||||
.let { MessageDeleted(it.id, 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 {
|
||||
ProactiveMessage(
|
||||
it.platform, it.channelId, it.channelType, it.content, it.contentType,
|
||||
it.sender, it.timestamp, it.messageId, turnId, seq, sessionId,
|
||||
it.sender, it.timestamp.asStringOrNull(), it.messageId, it.id, it.createdAt,
|
||||
turnId, seq, sessionId,
|
||||
)
|
||||
}
|
||||
// 定向错误帧(含队列满的伴生帧):编码是开放集合,原样带上,未知码不做语义处置。
|
||||
"error" -> decode<ErrorData>(data).let {
|
||||
Error(
|
||||
it.message,
|
||||
it.code,
|
||||
it.clientMsgId,
|
||||
it.messageId,
|
||||
turnId,
|
||||
seq,
|
||||
sessionId ?: it.sessionId,
|
||||
)
|
||||
}
|
||||
"error" -> Error(decode<ErrorData>(data).message, turnId, seq, sessionId)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -503,6 +951,16 @@ sealed class WsEvent {
|
||||
|
||||
private inline fun <reified T> decode(data: JsonElement?): T =
|
||||
ApiClientFactory.json.decodeFromJsonElement<T>(data ?: JsonObject(emptyMap()))
|
||||
|
||||
/**
|
||||
* 与 [decode] 同源,但**解不出来就回 `null`**。
|
||||
*
|
||||
* 用在「帧本身还有意义、载荷却可能是坏形状」的地方:[MessageEdited] 的载荷缺失时,
|
||||
* 事件仍带着帧壳上的 `sessionId` 成形(上层据此整帧跳过),而不是把整帧按坏帧丢掉——
|
||||
* 丢掉会让「这一帧来过」这件事完全不可见。
|
||||
*/
|
||||
private inline fun <reified T> decodeOrNull(data: JsonElement?): T? =
|
||||
runCatching { decode<T>(data) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,6 +989,22 @@ private fun JsonElement?.asStringOrNull(): String? {
|
||||
return primitive.content
|
||||
}
|
||||
|
||||
/**
|
||||
* 帧壳顶层的 `proactive` 是否为真。
|
||||
*
|
||||
* 只认**字面量** `true`(布尔或字符串 `"true"`),其余(键缺席、`false`、`JsonNull`、数字、对象)
|
||||
* 一律按假——这个键的方向必须偏「不打扰」:判错成真会给一条普通消息弹出通知,而主人只批了
|
||||
* proactive 那一档;判错成假的最多是她主动说的话没弹通知(不打扰人,也不撒谎)。
|
||||
*/
|
||||
private fun JsonObject.frameProactive(): Boolean = this["proactive"].asTrue()
|
||||
|
||||
/**
|
||||
* `proactive` 那个键的**宽容**读法(只认字面 `true`;`JsonNull` / 别的形状一律假)。
|
||||
* 帧壳与载荷两处共用这一份口径,免得同一个键在两处各判一次、各自漂移。
|
||||
*/
|
||||
private fun JsonElement?.asTrue(): Boolean =
|
||||
(this as? JsonPrimitive)?.content?.toBooleanStrictOrNull() == true
|
||||
|
||||
@Serializable
|
||||
private data class IndexData(val index: Int = 0)
|
||||
|
||||
@@ -548,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(
|
||||
@@ -555,6 +1033,7 @@ private data class ToolExecutionData(
|
||||
val phase: String = "",
|
||||
val id: String? = null,
|
||||
val delta: String = "",
|
||||
val resultSummary: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -571,16 +1050,68 @@ private data class StreamEndData(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SessionModeData(val sessionId: String = "", val mode: String = "")
|
||||
private data class SessionModeData(
|
||||
val sessionId: String = "",
|
||||
val mode: String = "",
|
||||
/**
|
||||
* `session_busy` / `session_idle` 的**可选**在途路数(忙时并行规范 §2.6)。
|
||||
* `null` = 服务端没给这个键(v0.65 之前)——**不是 0**:0 是「确实没有在途路」,
|
||||
* 缺席是「不知道」,两者绝不能互相顶替(拿缺席当 0 会把并行两路误报成空闲)。
|
||||
*/
|
||||
val activeTurns: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PendingData(val sessionId: String = "", val mode: String = "", val queueDepth: Int = 0)
|
||||
private data class PendingData(
|
||||
val sessionId: String = "",
|
||||
val mode: String = "",
|
||||
val queueDepth: Int = 0,
|
||||
/** 见 [WsEvent.SessionBusy.activeTurns](`pending_ack` / `pending_drain` 各带一份)。 */
|
||||
val activeTurns: Int? = null,
|
||||
/** 入队原因码(规范 §2.6,目前只有 `"parallel_limit"`);缺席 = `null`,绝不伪造。 */
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class InterruptedData(val sessionId: String = "", val mode: String = "", val dropped: Int = 0)
|
||||
private data class InterruptedData(
|
||||
val sessionId: String = "",
|
||||
val mode: String = "",
|
||||
val dropped: Int = 0,
|
||||
val activeTurns: Int? = null,
|
||||
val cancelledTurns: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `turn_begin` 载荷(忙时并行规范 §2.4):`{kind, messageIds, leg, activeTurns}`。
|
||||
*
|
||||
* `sessionId` 在规范里**不出现在载荷中**(只在帧壳);这里仍留一个可选键并让它优先——与
|
||||
* [WsEvent.resolveSessionId] 对其它帧的既有口径一致(帧壳缺席时服务端若给了,用它比丢掉好)。
|
||||
*/
|
||||
@Serializable
|
||||
private data class PendingFullData(val sessionId: String = "", val mode: String = "", val max: Int = 0)
|
||||
private data class TurnBeginData(
|
||||
val sessionId: String = "",
|
||||
val kind: String = "",
|
||||
val messageIds: List<String> = emptyList(),
|
||||
val leg: Int = 0,
|
||||
val activeTurns: Int = 0,
|
||||
)
|
||||
|
||||
/** `steer_ack` 载荷(v0.57 一字未变):`{messageId, atStep}`;`sessionId` 由帧壳给。 */
|
||||
@Serializable
|
||||
private data class SteerAckData(val sessionId: String = "", val messageId: String = "", val atStep: Int = 0)
|
||||
|
||||
/**
|
||||
* `pending_full` 载荷:`{sessionId, mode, max, clientMsgId?, messageId?}`。前三个键来自加入
|
||||
* `G5` 之前,逐字未变;后两个是 v0.59 的纯增量,服务端**整键缺席**而不是填空串。
|
||||
*/
|
||||
@Serializable
|
||||
private data class PendingFullData(
|
||||
val sessionId: String = "",
|
||||
val mode: String = "",
|
||||
val max: Int = 0,
|
||||
val clientMsgId: String? = null,
|
||||
val messageId: String? = null,
|
||||
)
|
||||
|
||||
/** `chat_duplicate` 载荷(v0.56 §3.13):`{sessionId, clientMsgId, messageId}`。 */
|
||||
@Serializable
|
||||
@@ -594,17 +1125,68 @@ private data class ChatDuplicateData(
|
||||
@Serializable
|
||||
private data class SessionReadData(val readAt: Long = 0L)
|
||||
|
||||
/**
|
||||
* `message_persisted` / `channel_persisted` 载荷:`{message:{…}}`。
|
||||
*
|
||||
* [proactive] 是「这句话是她主动说的」(见 [WsEvent.MessagePersisted])。它此前**不在**这里,
|
||||
* 于是被 `ignoreUnknownKeys = true` 静静吃掉——这正是 W53N 要修的那一条:判据键读不回来,
|
||||
* 「她主动说的话」在客户端就只剩 `proactive_message` 那一条路可见。
|
||||
*
|
||||
* ⚠️ 声明成 [JsonElement] 而不是 `Boolean` 是**修一类能把整帧弄没的解码缺陷**(同
|
||||
* [ProactiveData.timestamp] 那条教训):契约没说这个键的类型,而线上只要给的是 `"true"` 这种
|
||||
* 字符串形状,`Boolean` 解码就会抛、被 `parse` 的 `runCatching` 吞成 `null` ⇒ **整帧消失**,
|
||||
* 连普通消息的落库广播都跟着受影响。收成元素之后按字面量读(见 [asTrue]),一个判据键从此
|
||||
* 不可能让一整类帧消失。
|
||||
*/
|
||||
@Serializable
|
||||
private data class PersistedData(val message: HistoryMessageDto? = null)
|
||||
private data class PersistedData(
|
||||
val message: HistoryMessageDto? = null,
|
||||
val proactive: JsonElement? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `message_deleted` 载荷(§3.8):`{id}`。缺键 / 空串解成空 `id`,上层据此整帧跳过
|
||||
* (见 [top.yeij.cyrene.data.repository.removeMessageById]),不会误删。
|
||||
*/
|
||||
@Serializable
|
||||
private data class DeletedData(val id: String = "")
|
||||
|
||||
/** `observability_trace` 载荷(后端 `gateway.AdminTracePush`):`{spans:[…]}`,最新在前。 */
|
||||
@Serializable
|
||||
private data class TracePushData(val spans: List<AdminTraceSpanDto> = emptyList())
|
||||
|
||||
/** `observability_log` 载荷(后端 `gateway.AdminLogPush`):`{entries:[…]}`,最新在前。 */
|
||||
@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,
|
||||
)
|
||||
|
||||
/**
|
||||
* `proactive_message` 载荷(v0.72;字段名对着服务端 `gateway.proactivePayload`)。
|
||||
*
|
||||
* [timestamp] 声明成 [JsonElement] 而不是 `Long` 是**修一个把整帧弄没的解码缺陷**:
|
||||
* 服务端那是 Go `time.Time`,线上是 RFC3339 **字符串**,而本端此前按 `Long?` 解 ⇒ 解码抛异常、
|
||||
* 被 `parse` 的 `runCatching` 吞成 `null`,帧**在成形之前就没了**(`onEvent` 连 `else` 都到不了)。
|
||||
* 收成元素之后按字面量取(数字形状也照样读出它),一个只作诊断的键从此不可能让整帧消失。
|
||||
*
|
||||
* [id] / [createdAt] 是落库行的权威 id 与时间戳,落库失败时**一起缺席**。
|
||||
*/
|
||||
@Serializable
|
||||
private data class ProactiveData(
|
||||
val platform: String? = null,
|
||||
@@ -613,9 +1195,22 @@ private data class ProactiveData(
|
||||
val content: String = "",
|
||||
val contentType: String? = null,
|
||||
val sender: String? = null,
|
||||
val timestamp: Long? = null,
|
||||
val timestamp: JsonElement? = null,
|
||||
val messageId: String? = null,
|
||||
val id: String? = null,
|
||||
val createdAt: Long? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `error` 帧载荷。`code` / `sessionId` / `clientMsgId` / `messageId` 都是 v0.59 起的可选键
|
||||
* (队列满的定向伴生帧带的正是这四个加 `message`),其余错误帧只带 `message`,
|
||||
* 甚至**一个字段都不带**(如 `{code:"bad_request"}`)——都按「拿不到就不做事」解码。
|
||||
*/
|
||||
@Serializable
|
||||
private data class ErrorData(val message: String = "")
|
||||
private data class ErrorData(
|
||||
val message: String = "",
|
||||
val code: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val clientMsgId: String? = null,
|
||||
val messageId: String? = null,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
@@ -32,7 +33,7 @@ sealed interface WsOutbound {
|
||||
data()?.let { put("data", it) }
|
||||
}.toString()
|
||||
|
||||
/** `chat`:`{sessionId?, text, image?, deviceId?, clientMsgId?}`;text+image 至少一项。 */
|
||||
/** `chat`:`{sessionId?, text, image?, deviceId?, clientMsgId?, parallel?}`;text+image 至少一项。 */
|
||||
data class Chat(
|
||||
val text: String,
|
||||
val sessionId: String? = null,
|
||||
@@ -43,6 +44,17 @@ sealed interface WsOutbound {
|
||||
* 重复到达服务端回下行帧 `chat_duplicate`、**不落库、不起回合**。省略 = 老行为逐字不变。
|
||||
*/
|
||||
val clientMsgId: String? = null,
|
||||
/**
|
||||
* **请求把本条作为独立一路处理**(忙时并行规范 §2.2,**纯增量**)。
|
||||
*
|
||||
* `null`(默认)= **整键省略**:行为与加入该字段之前**逐字相同**(忙时落回排队 + 回合内 steer)。
|
||||
* 用户把「她忙时发消息」那栏选成「并行」**且会话正忙**时才给 `true`(判据是纯函数
|
||||
* `parallelForSend`;另外两档与空闲时都不带这个键——线上取值由 `parallelWireValue` 翻)。
|
||||
*
|
||||
* 服务端**照准也不报错**(额度满 ⇒ 落回排队并回 `pending_ack{reason:"parallel_limit"}`;
|
||||
* 已被在飞路 steer 吃进 ⇒ 回 `steer_ack`),所以这个字段永远不该引来一个错误帧。
|
||||
*/
|
||||
val parallel: Boolean? = null,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "chat"
|
||||
@@ -53,10 +65,13 @@ sealed interface WsOutbound {
|
||||
image?.let { put("image", it) }
|
||||
deviceId?.let { put("deviceId", it) }
|
||||
clientMsgId?.let { put("clientMsgId", it) }
|
||||
// 布尔字面量走 JsonPrimitive:`put(key, Boolean)` 的重载在不同 kotlinx 版本里形态不一,
|
||||
// 显式构造不会有歧义,也保证 `null` 时**整键省略**(与后端 `omitempty` 同义)。
|
||||
parallel?.let { put("parallel", JsonPrimitive(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `interrupt`:`{sessionId, scope?}`。命中空闲会话为 no-op。 */
|
||||
/** `interrupt`:`{sessionId, scope?, turnId?}`。命中空闲会话为 no-op。 */
|
||||
data class Interrupt(
|
||||
val sessionId: String,
|
||||
/**
|
||||
@@ -67,6 +82,18 @@ sealed interface WsOutbound {
|
||||
* 不做取值校验、不静默回退成默认(服务端的开放取值域由服务端裁决)。
|
||||
*/
|
||||
val scope: String? = null,
|
||||
/**
|
||||
* 只取消**这一路**(忙时并行规范 §2.3,**纯增量**)。
|
||||
*
|
||||
* 含义按 [scope] 分(规范的表):`"current"` + 给了 `turnId` ⇒ **只停该路**,其余路与 pending
|
||||
* 不受影响;`"current"` + 省略 ⇒ 取消**全部在途路**、保留 pending(现状语义的自然推广);
|
||||
* 省略 / `""` / `"all"` ⇒ 忽略本字段(取消全部 + 清 pending)。
|
||||
*
|
||||
* `null` = **整键省略** ⇒ 与加入该字段之前**逐字节相同**。本端今天同样一个字节都不发它:
|
||||
* 唯一的调用点(`ChatViewModel` 的「她正忙时按发送」偏好)走的正是「取消全部在途路」那一档,
|
||||
* 不需要按路精确停(规范未决问题 12 的建议默认:默认目标 = 全部在途路)。
|
||||
*/
|
||||
val turnId: String? = null,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "interrupt"
|
||||
@@ -74,15 +101,30 @@ sealed interface WsOutbound {
|
||||
override fun data(): JsonElement = buildJsonObject {
|
||||
put("sessionId", sessionId)
|
||||
scope?.let { put("scope", it) }
|
||||
turnId?.let { put("turnId", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `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"
|
||||
@@ -92,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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@ enum class WsState {
|
||||
/** 连接断开,正在按退避策略等待重连。 */
|
||||
Reconnecting,
|
||||
|
||||
/** 不可恢复(握手 401 / 缺少 token 或地址),不会自动重连。 */
|
||||
/**
|
||||
* 不可恢复(握手 401 / 缺少 token 或地址),不会自动重连。
|
||||
*
|
||||
* ⚠️ **握手拿到「非 101 且非 401」的响应不属于这一档**:那种失败(例如中间设备/代理直接回
|
||||
* `503`、`502`,或握手被打断)在 [ChatSocket.isAuthFailure] 里判不出鉴权失败 ⇒ 走
|
||||
* [Reconnecting] 按退避**继续重连**。判据只认 `response.code == 401`(拿不到 response 时再退化
|
||||
* 成消息文本里含 `401`),所以「代理回 503」时 socket 的真值是 [Reconnecting],不是 [Offline]。
|
||||
* 这一条在发送阶梯上很要命:它决定了「WS 未就绪」时**要不要**去试一次 REST
|
||||
* ([top.yeij.cyrene.ui.chat.sendRouteFor] 只在真 [Offline] 上判进队列、不试;W41 起如此,
|
||||
* W45 起同一条判据仍然成立,只是那一步从「探一次健康端点」换成了「直接试 REST」)。
|
||||
*/
|
||||
Offline,
|
||||
}
|
||||
@@ -56,5 +56,36 @@ enum class AdminPage(
|
||||
/** 当前角色能否使用该页;不能则 UI 禁用入口、ViewModel 直接给「无权限」。 */
|
||||
fun canAccess(role: AdminRole, page: AdminPage): Boolean = role.rank >= page.required.rank
|
||||
|
||||
/** 当前角色可用的页,顺序与 [AdminPage] 声明一致。 */
|
||||
fun availablePages(role: AdminRole): List<AdminPage> = AdminPage.entries.filter { canAccess(role, it) }
|
||||
/**
|
||||
* 能否接收可观测实时推送帧(`observability_metrics` / `observability_trace` /
|
||||
* `observability_log`,契约 §3.12)。
|
||||
*
|
||||
* 这三帧**不在**那 7 个「不含内容的状态机信号」白名单里,服务端按 fail-closed 只发
|
||||
* **owner + admin**。注意这与 [AdminPage.Overview] 的可见性**是两回事**:概览页本身是
|
||||
* [AdminTier.Monitor],`viewer` 也能进、也能读它背后的三个 REST 端点(v0.40 只读监控档),
|
||||
* 但它**一帧推送都收不到**(服务端不推,客户端也就不该开连接去等)。
|
||||
*
|
||||
* 客户端按同一档自行降级:不够档就**不开连接、不合并帧**,而不是等「怎么一直没帧」再猜。
|
||||
* 注意概览页背后的三个 REST 读取(metrics / trace / logs)都落在同一档只读监控里、`viewer` 全读得到:
|
||||
* 也就是 `viewer` 看得到**进页面那一刻的快照**、收不到任何增量。这条不对称对三块都成立,
|
||||
* 日志那块之所以另写成 [canReadObservabilityLogHistory],是因为它还要决定「区块渲不渲染」。
|
||||
*/
|
||||
fun canReceiveObservabilityFrames(role: AdminRole): Boolean =
|
||||
role.rank >= AdminTier.OwnerAdmin.rank
|
||||
|
||||
/**
|
||||
* 能否读取「最近日志」的**历史**(`GET /api/v1/admin/observability/logs`)。
|
||||
*
|
||||
* 该端点在 v0.40 的 12 条 `viewerAuth` 只读监控档里,与 [AdminPage.Overview] 同档 ⇒
|
||||
* `viewer` 也读得到。它与 [canReceiveObservabilityFrames] **刻意不对称**,这是契约的安排、
|
||||
* 不是缺口:
|
||||
*
|
||||
* - `viewer`:读得到进页面那一刻的历史,**收不到**之后的增量;
|
||||
* - `owner` / `admin`:历史与增量都有。
|
||||
*
|
||||
* ⇒ 「最近日志」区块的**内容**只由这次历史读取决定;收不到增量既不是错误也不是空态,
|
||||
* UI 不得据此给出任何失败 / 空提示,也不该为它开一条连接去干等(连接只在
|
||||
* [canReceiveObservabilityFrames] 够档时才建立)。写成纯函数是为了让这条口径只有一个落点,
|
||||
* 而不是散在各处 `if` 里靠人记。
|
||||
*/
|
||||
fun canReadObservabilityLogHistory(role: AdminRole): Boolean = canAccess(role, AdminPage.Overview)
|
||||
@@ -26,6 +26,17 @@ fun formatRfc3339(raw: String?): String? {
|
||||
}.getOrDefault(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC3339 → epoch 秒,供按时间排序用(日志 `time` 只有秒精度,同秒是很常见的并列)。
|
||||
*
|
||||
* 解析不了返回 null——**绝不**在这里退回「当成 Unix 毫秒」:日志 / trace 的 `time` 是 RFC3339,
|
||||
* backthink 的 `at` 才是 Unix 毫秒([formatEpochMillis]),两种形状刻意不共用解析路径
|
||||
* (api-client §1)。调用方对 null(时间判不出来)需自行给一个保守的次序,但**不得丢弃**条目。
|
||||
*/
|
||||
fun rfc3339EpochSecond(raw: String?): Long? = raw
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { runCatching { OffsetDateTime.parse(it).toEpochSecond() }.getOrNull() }
|
||||
|
||||
/**
|
||||
* Unix 毫秒(backthink 的 `at`)→ 本地时间文本。
|
||||
* 与 [formatRfc3339] 是**两套输入**,刻意不共用解析路径(api-client §1 的两种时间形状)。
|
||||
@@ -119,7 +130,8 @@ data class ConclusionParagraph(val parts: List<ContentPart>)
|
||||
*
|
||||
* 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `<p></p>`、
|
||||
* `<action>` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]:
|
||||
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。
|
||||
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签由 [parse] 兜底
|
||||
* (落单的段落标签剥掉,其余按普通文本回落)。
|
||||
* 空段(`<p></p>` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。
|
||||
* 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。
|
||||
* `null` / 纯空白输入返回空列表。
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
/**
|
||||
* `perPath` 的一项:路径原文 + 命中次数 + 展示文本。
|
||||
*
|
||||
* [path] **不做任何改写**(便于与网关日志 / 后端逐字对齐、复制粘贴),
|
||||
* 手机上要读的那一份在 [display](见 [PathStats.displayPath])。
|
||||
*/
|
||||
data class PathHit(
|
||||
val path: String,
|
||||
val count: Long,
|
||||
val display: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 同一个前缀下的一组路径,组内已按命中次数倒序(同数按路径升序)。
|
||||
* [hits] / [pathCount] 是组头要显示的两个数:**条数与次数不是一回事**
|
||||
* (真实响应里 `/admin` 一组 = 15 条路径、283 次命中)。
|
||||
*/
|
||||
data class PathGroup(
|
||||
val prefix: String,
|
||||
val paths: List<PathHit>,
|
||||
) {
|
||||
val hits: Long get() = paths.sumOf { it.count }
|
||||
|
||||
val pathCount: Int get() = paths.size
|
||||
|
||||
/**
|
||||
* 默认展开还是收起:条数多的大组默认**收起**,首屏因此只留一行组头,
|
||||
* 展开是用户的一次明确动作(见 [PathStats.COLLAPSE_THRESHOLD])。
|
||||
*/
|
||||
val expandedByDefault: Boolean get() = pathCount <= PathStats.COLLAPSE_THRESHOLD
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组列表里的一行。[Header] 可点、可折叠;[Leaf] 是一条路径。
|
||||
* 只有一条路径的组**不产生组头**(组头与内容完全重复),直接给一条 [Leaf]。
|
||||
*/
|
||||
sealed interface PathRow {
|
||||
data class Header(val group: PathGroup) : PathRow
|
||||
|
||||
/** [nested] = 该行在一个已展开的组里,UI 据此缩进;组外散条不缩进。 */
|
||||
data class Leaf(val hit: PathHit, val nested: Boolean) : PathRow
|
||||
}
|
||||
|
||||
/**
|
||||
* 「按路径」统计的分组与展示模型(纯函数,不含任何 Compose / Android 依赖)。
|
||||
*
|
||||
* ## 为什么要客户端分组
|
||||
* 契约(api-client §2「可观测性契约」)里 `perPath` 是**扁平**的 `路径 → 次数` 映射——
|
||||
* 真实响应 26 条,后端不提供层级信息。之前它被塞进一行右对齐文本([formatCounts]),
|
||||
* 手机上 26 条挤成一团、不可读,所以这里定义一套**可解释的分组规则**:
|
||||
*
|
||||
* **分组键 = 剥掉 [API_BASE_PREFIX] 后的首段**(`/api/v1/admin/llm/catalog` → `/admin`,
|
||||
* `/assets/index-x.js` → `/assets`,`/sw.js` → `/sw.js`,`/` → `/`)。
|
||||
*
|
||||
* - **没有任何路径清单**:规则是纯字符串推导,服务端新增路由、新增路径形状都不需要改客户端
|
||||
* (未知前缀自动成组,见单测里的 `/api/v1/quantum/entangle`)。
|
||||
* - 剥掉 `/api/v1` 是因为它对 21/26 条路径完全相同,占满宽度却零区分度;
|
||||
* 剥离**只影响分组键**,行里显示的仍是完整路径。
|
||||
* - 比「按前两段分组」好:那样 `/admin/automations`、`/admin/personas` … 会切出十几个
|
||||
* 只有一条路径的组,组头与内容重复,比不分组还难读。
|
||||
* - 深层路径(段数 > [MAX_DISPLAY_SEGMENTS])在展示时**折叠中间段、保留尾部**:
|
||||
* 尾部才是把同类路径区分开的部分(`…/admin/observability/metrics`),而整串在手机上
|
||||
* 会被省略号吃掉尾部、丢掉区分度。
|
||||
*
|
||||
* 排序:组按 [PathGroup.hits] 倒序(热路径在前),组内按次数倒序——两处同数都按路径升序,
|
||||
* 保证刷新前后顺序稳定(指标页每次刷新都重排会让读者跟丢)。
|
||||
*/
|
||||
object PathStats {
|
||||
|
||||
/** 所有 REST 端点共有的基址段,只在**分组键**里剥掉。 */
|
||||
const val API_BASE_PREFIX = "/api/v1"
|
||||
|
||||
/** 组内路径条数超过它就默认收起。4 条以内的小组直接展开,省一次点击。 */
|
||||
const val COLLAPSE_THRESHOLD = 4
|
||||
|
||||
/** 段数超过它的路径在展示时折叠中间段。 */
|
||||
const val MAX_DISPLAY_SEGMENTS = 4
|
||||
|
||||
/** 折叠后保留的尾部段数。 */
|
||||
const val RETAINED_TAIL_SEGMENTS = 3
|
||||
|
||||
/** 根路径(`/`)与异常空串的组名。 */
|
||||
private const val ROOT_PREFIX = "/"
|
||||
|
||||
private val API_BASE_SEGMENTS = segments(API_BASE_PREFIX)
|
||||
|
||||
/**
|
||||
* `perPath` → 分组列表:组按命中次数倒序,组内路径同样倒序。
|
||||
* 空输入给空列表(UI 据此渲染「暂无路径统计」,**它是成功后的空**,与失败态无关)。
|
||||
*/
|
||||
fun groupPaths(perPath: Map<String, Long>): List<PathGroup> =
|
||||
perPath.entries
|
||||
.groupBy(
|
||||
keySelector = { groupPrefix(it.key) },
|
||||
valueTransform = { PathHit(path = it.key, count = it.value, display = displayPath(it.key)) },
|
||||
)
|
||||
.map { (prefix, paths) ->
|
||||
PathGroup(
|
||||
prefix = prefix,
|
||||
paths = paths.sortedWith(compareByDescending<PathHit> { it.count }.thenBy { it.path }),
|
||||
)
|
||||
}
|
||||
.sortedWith(compareByDescending<PathGroup> { it.hits }.thenBy { it.prefix })
|
||||
|
||||
/**
|
||||
* 展开状态:**只记用户动过的组**([toggled] = 组前缀 → 是否展开),
|
||||
* 其余按 [PathGroup.expandedByDefault]。这样刷新拿到新数据后,默认收起的大组不会被
|
||||
* 上一轮的点按状态锁死,新出现的分组也自然落到默认档。
|
||||
*/
|
||||
fun expanded(group: PathGroup, toggled: Map<String, Boolean> = emptyMap()): Boolean =
|
||||
toggled[group.prefix] ?: group.expandedByDefault
|
||||
|
||||
/** 分组列表 + 展开状态 → 一维行序列,UI 直接顺序渲染(组头之后紧跟它的子行)。 */
|
||||
fun rows(groups: List<PathGroup>, toggled: Map<String, Boolean> = emptyMap()): List<PathRow> =
|
||||
groups.flatMap { group ->
|
||||
// 两个分支都写明 [PathRow]:否则 `flatMap` 会拿第一个分支的元素类型
|
||||
// (`PathRow.Leaf`)去约束第二个分支,组头就加不进去了。
|
||||
if (group.pathCount == 1) {
|
||||
listOf<PathRow>(PathRow.Leaf(group.paths.single(), nested = false))
|
||||
} else {
|
||||
buildList<PathRow> {
|
||||
add(PathRow.Header(group))
|
||||
if (expanded(group, toggled)) {
|
||||
group.paths.forEach { add(PathRow.Leaf(it, nested = true)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示文本:段数不超过 [MAX_DISPLAY_SEGMENTS] 时**原样返回**(不做任何归一化,
|
||||
* 尾斜杠之类都保留);超过则折叠中间段、保留尾部 [RETAINED_TAIL_SEGMENTS] 段,
|
||||
* 前缀 `…/` 明确表示左边还有内容被省掉。
|
||||
*/
|
||||
fun displayPath(path: String): String {
|
||||
val parts = segments(path)
|
||||
if (parts.size <= MAX_DISPLAY_SEGMENTS) return path
|
||||
return "…/" + parts.takeLast(RETAINED_TAIL_SEGMENTS).joinToString("/")
|
||||
}
|
||||
|
||||
/** 分组键:剥掉 [API_BASE_PREFIX] 后的首段,逐段比较(`/api/v1x/a` 不算基址下的路径)。 */
|
||||
private fun groupPrefix(path: String): String {
|
||||
val parts = segments(path)
|
||||
if (parts.isEmpty()) return ROOT_PREFIX
|
||||
val rest = if (parts.take(API_BASE_SEGMENTS.size) == API_BASE_SEGMENTS) {
|
||||
parts.drop(API_BASE_SEGMENTS.size)
|
||||
} else {
|
||||
parts
|
||||
}
|
||||
return rest.firstOrNull()?.let { "/$it" } ?: API_BASE_PREFIX
|
||||
}
|
||||
|
||||
private fun segments(path: String): List<String> = path.split('/').filter { it.isNotEmpty() }
|
||||
}
|
||||
@@ -29,9 +29,16 @@ object ChatImageBudget {
|
||||
/** `data:` URI 前缀(`data:image/jpeg;base64,`)的字符余量,与服务端同一取值。 */
|
||||
const val PREFIX_ALLOWANCE_CHARS = 64
|
||||
|
||||
/** 服务端 `image` 字符串长度上限(= base64 膨胀 4/3 + 前缀余量)。 */
|
||||
const val SERVER_MAX_CHARS =
|
||||
((SERVER_MAX_DECODED_BYTES + 2) / 3) * 4 + PREFIX_ALLOWANCE_CHARS
|
||||
/** [byteCount] 字节 base64 编码后的字符数(每 3 字节 → 4 字符,末组补满)。 */
|
||||
fun base64Chars(byteCount: Int): Int = ((byteCount + 2) / 3) * 4
|
||||
|
||||
/**
|
||||
* 服务端 `image` 字符串长度上限(= [base64Chars] 的 4/3 膨胀 + 前缀余量)。
|
||||
*
|
||||
* 刻意复用 [base64Chars] 而不是把公式再内联写一遍:公式只留一处。代价是它只能是 `val`
|
||||
* ——`const val` 不允许调用函数;取值在对象初始化时算出,与内联写法一致。
|
||||
*/
|
||||
val SERVER_MAX_CHARS: Int = base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS
|
||||
|
||||
/** 本端目标:重编码后的**字节**上限,远低于服务端硬顶(见类注释)。 */
|
||||
const val MAX_DECODED_BYTES = 1 * 1024 * 1024
|
||||
@@ -42,9 +49,6 @@ object ChatImageBudget {
|
||||
*/
|
||||
const val MAX_DIMENSION_PX = 1280
|
||||
|
||||
/** [byteCount] 字节 base64 编码后的字符数(每 3 字节 → 4 字符,末组补满)。 */
|
||||
fun base64Chars(byteCount: Int): Int = ((byteCount + 2) / 3) * 4
|
||||
|
||||
/** 字符串长度是否在服务端上限内(入口层同一判据:`<=` 才算通过)。 */
|
||||
fun withinServerLimit(dataUriChars: Int): Boolean = dataUriChars <= SERVER_MAX_CHARS
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ enum class SendChannel {
|
||||
* 且 `stream:true` 的回合同样在**已连接的 WS** 上流式回来 ⇒ 「REST 发、WS 收」不牺牲流式体验。
|
||||
*
|
||||
* 图片一律走 REST:真实图片的 base64 远超 4096 字节,在 WS 上**必然**断连。
|
||||
* 文档附件同理一律走 REST(上传换引用这件事整个压在同一条通道上,见 [DocumentDelivery])。
|
||||
*
|
||||
* ## 阈值
|
||||
* 判据是**文本的 UTF-8 字节数**(不是字符数——中文一个字 3 字节,按字符数算会在
|
||||
@@ -46,9 +47,17 @@ object ChatSendRouting {
|
||||
/**
|
||||
* 这一条该走哪条通道。
|
||||
* @param hasImage 本次是否带图片(带图**永远**走 REST)
|
||||
* @param hasDocuments 本次是否带**文档附件**(带附件**永远**走 REST):文档本体要先经
|
||||
* `POST /api/v1/documents` 上传,引用只随 `POST /api/v1/chat` 发出——
|
||||
* 「文档一律走 REST」见 [DocumentDelivery] 第 2 条。默认 false ⇒ 既有调用与旧行为逐字不变。
|
||||
*/
|
||||
fun channelFor(text: String, hasImage: Boolean): SendChannel = when {
|
||||
fun channelFor(
|
||||
text: String,
|
||||
hasImage: Boolean,
|
||||
hasDocuments: Boolean = false,
|
||||
): SendChannel = when {
|
||||
hasImage -> SendChannel.Rest
|
||||
hasDocuments -> SendChannel.Rest
|
||||
utf8Length(text) <= WS_TEXT_LIMIT_BYTES -> SendChannel.Ws
|
||||
else -> SendChannel.Rest
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImages` 平行的概念)。
|
||||
*
|
||||
* 与 [DocumentFile] 的分工:[DocumentFile] 是「发送那一刻的字节」——上传需要它;
|
||||
* 本类型是「还没发送的引用」——输入栏上方的芯片要显示它,而**绝不能**把最多 5×20 MiB
|
||||
* 的字节一直摆在 UI 状态里(`ByteArray` 一进 `StateFlow` 就等于每次重组都摸一遍它)。
|
||||
* 字节只在真的要上传时按 [uri] 现读(见 `ChatViewModel` 的发送路径)。
|
||||
*
|
||||
* 不用 `displayName` 去拼本地路径、也不改它的大小写:它**只用来给上传部件命名**
|
||||
* (服务端要的是扩展名,其余部分会被内容寻址丢掉),以及给用户看。
|
||||
*/
|
||||
data class DraftDocument(
|
||||
/** 本地可读 URI(SAF 的 `content://` 或分享落地后的 `file://`)。 */
|
||||
val uri: String,
|
||||
/** 给用户看的名字,含扩展名;空 = 让仓库层兜 `document.txt`。 */
|
||||
val displayName: String,
|
||||
/** 元数据里报的字节数;[UNKNOWN_SIZE] = 拿不到(此时不做本地体积预检,交给服务端裁决)。 */
|
||||
val sizeBytes: Long = UNKNOWN_SIZE,
|
||||
) {
|
||||
companion object {
|
||||
/** `ContentResolver` 拿不到 `SIZE` 时的取值:**不猜**,也不因此拒绝这个文件。 */
|
||||
const val UNKNOWN_SIZE: Long = -1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次「选文件」的结果:接受了哪些、又有哪些**没能接受**。
|
||||
*
|
||||
* 三个计数分开记,因为它们该说的话不一样,而且**一个都不许静默消失**——
|
||||
* 「用户以为附件都挂上了、其实少了两个」正是本模块要消灭的东西。
|
||||
*/
|
||||
data class AttachmentPickResult(
|
||||
val accepted: List<DraftDocument> = emptyList(),
|
||||
/** 超过 [DocumentAttachments.MAX_FILE_BYTES] 而未被接受的显示名。 */
|
||||
val tooLarge: List<String> = emptyList(),
|
||||
/** 本回合附件已满([DocumentAttachments.MAX_PER_TURN])而未被接受的份数。 */
|
||||
val overLimit: Int = 0,
|
||||
/** 与已选附件重复、因此没有重复加入的份数。 */
|
||||
val duplicate: Int = 0,
|
||||
) {
|
||||
/** 如实告知的那句话;没有任何东西被挡下时回 `null`(正常选中一个字都不说)。 */
|
||||
val notice: String?
|
||||
get() {
|
||||
val parts = buildList {
|
||||
if (tooLarge.isNotEmpty()) {
|
||||
add(
|
||||
"有 ${tooLarge.size} 个文件超过 " +
|
||||
"${DocumentAttachments.maxFileSizeLabel}," +
|
||||
"没有加入(${tooLarge.joinToString("、")})",
|
||||
)
|
||||
}
|
||||
if (overLimit > 0) {
|
||||
add("一个回合最多 ${DocumentAttachments.MAX_PER_TURN} 个附件,另有 $overLimit 个没有加入")
|
||||
}
|
||||
if (duplicate > 0) add("有 $duplicate 个和已选附件重复,没有重复加入")
|
||||
}
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(";")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 待发附件的准入规则(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 它为什么在客户端也要有一份
|
||||
* 服务端才是裁决者(上限是 `CHAT_DOCUMENT_MAX_BYTES`,可部署配置;类型裁决只有字节嗅探一条),
|
||||
* 所以这里的预检**只做一件服务端做不到的事**:**在把字节传上去之前**就说话。
|
||||
* 一份 30 MiB 的文件照传一遍再吃 `413`,用户等的是上传的那几分钟,而结论本端现在就能给。
|
||||
* 代价是这份上限可能与部署实际不同——因此一旦两边不一致,**服务端的话更权威**:
|
||||
* 本端放过去的,服务端照旧能用 `413` / `415` 拦下(那两条文案会原样显示给用户)。
|
||||
*
|
||||
* ## 为什么不裁剪
|
||||
* [admit] 只**不收**,从不**少收**:多出来的份数与超限的文件都进 [AttachmentPickResult],
|
||||
* 由调用方如实告知。服务端对 >5 个回 `400 too_many_documents`、对非法名字回 `404`,
|
||||
* 两边都不做静默裁剪,客户端也就不该在本地悄悄只发前 5 个。
|
||||
*/
|
||||
object DocumentAttachments {
|
||||
|
||||
/**
|
||||
* 单份附件的本端预检上限。
|
||||
*
|
||||
* 取服务端文档上限的**默认值**(`CHAT_DOCUMENT_MAX_BYTES`,契约 v0.59 §1.2 = 20 MiB)。
|
||||
* 刻意与图片上限(5 MiB,[top.yeij.cyrene.data.repository.UploadRepository] 那条)分开:
|
||||
* 服务端把两者解耦,客户端合成一个数就会先自己把文档卡在图片的量级上。
|
||||
*/
|
||||
const val MAX_FILE_BYTES: Long = 20L * 1024 * 1024
|
||||
|
||||
/** 上限的可读说法(给提示文案用,避免两处各写一个数字)。 */
|
||||
const val maxFileSizeLabel: String = "20 MB"
|
||||
|
||||
/** 一个回合的附件数上限,与判定矩阵同源([DocumentDelivery.MAX_ATTACHMENTS_PER_TURN])。 */
|
||||
const val MAX_PER_TURN: Int = DocumentDelivery.MAX_ATTACHMENTS_PER_TURN
|
||||
|
||||
/**
|
||||
* 把这次选中的文件并进已选列表。
|
||||
*
|
||||
* 顺序:**去重 → 体积 → 份数**。去重放在最前,是因为重复选同一份文件既不该被算成
|
||||
* 「超限」也不该被算成「超体积」——它压根不是一个新附件。
|
||||
*
|
||||
* @param existing 已选附件(输入栏上方那些芯片)。
|
||||
* @param picked 本次选中的文件(顺序即用户看到的顺序)。
|
||||
*/
|
||||
fun admit(existing: List<DraftDocument>, picked: List<DraftDocument>): AttachmentPickResult {
|
||||
val seen = existing.mapTo(mutableSetOf()) { it.uri }
|
||||
val tooLarge = mutableListOf<String>()
|
||||
val fresh = mutableListOf<DraftDocument>()
|
||||
var duplicate = 0
|
||||
|
||||
picked.forEach { document ->
|
||||
if (!seen.add(document.uri)) {
|
||||
duplicate++
|
||||
return@forEach
|
||||
}
|
||||
// 体积拿不到(-1)时不判:本端猜不准的东西,留给服务端的 413 说。
|
||||
if (document.sizeBytes > MAX_FILE_BYTES) {
|
||||
tooLarge += document.displayName.ifBlank { document.uri }
|
||||
return@forEach
|
||||
}
|
||||
fresh += document
|
||||
}
|
||||
|
||||
val room = (MAX_PER_TURN - existing.size).coerceAtLeast(0)
|
||||
val accepted = fresh.take(room)
|
||||
return AttachmentPickResult(
|
||||
accepted = accepted,
|
||||
tooLarge = tooLarge,
|
||||
overLimit = fresh.size - accepted.size,
|
||||
duplicate = duplicate,
|
||||
)
|
||||
}
|
||||
|
||||
/** 从已选列表里去掉第 [index] 个(越界即原样返回,UI 的移除按钮不必自己判界)。 */
|
||||
fun removeAt(existing: List<DraftDocument>, index: Int): List<DraftDocument> =
|
||||
if (index !in existing.indices) existing else existing.filterIndexed { i, _ -> i != index }
|
||||
|
||||
/**
|
||||
* URI 里能看出来的名字(`ContentResolver` 报不出 `DISPLAY_NAME` 时的兜底)。
|
||||
*
|
||||
* 取最后一段、去掉查询串;拿不到就回空串——**不猜**扩展名(扩展名真的参与服务端裁决,
|
||||
* 猜错等于替用户把一个合规文件变成 `415`)。
|
||||
*/
|
||||
fun fallbackDocumentName(uri: String): String =
|
||||
uri.substringBefore('?').substringAfterLast('/').takeIf { it.isNotBlank() }.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* 读一份附件的字节时会遇到的三种结果。
|
||||
*
|
||||
* 「太大」与「读不到」必须分开:前者的正确动作是换文件,后者是把这份内容**再读一次**
|
||||
* (授权过期、文件被删、provider 抽风)——折成一句「附件失败」会让用户做错事。
|
||||
*/
|
||||
sealed interface DocumentRead {
|
||||
|
||||
data class Ok(val bytes: ByteArray) : DocumentRead
|
||||
|
||||
/** 实际字节超过 [DocumentAttachments.MAX_FILE_BYTES]。 */
|
||||
data object TooLarge : DocumentRead
|
||||
|
||||
/** 打不开 / 读不动(授权失效、文件已不在、provider 报错)。 */
|
||||
data object Unavailable : DocumentRead
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 [input] 读至多 [limit] 字节。
|
||||
*
|
||||
* **返回值语义**:`null` = 这份内容**超过了** [limit];IO 异常**原样抛出**(调用方要能把它
|
||||
* 与「超限」分开说)。这样定是因为调用方必须给出不同的提示,而「读到一半发现超了」不能
|
||||
* 靠 `available()` 猜——`available()` 在 `content://` 流上不可靠。
|
||||
*
|
||||
* 读满 [limit] 就停:一份被换成 500 MiB 的文件(选完之后、发送之前被替换)不会先在
|
||||
* 内存里炸掉进程再报错。
|
||||
*/
|
||||
fun readBounded(input: InputStream, limit: Long): ByteArray? {
|
||||
val buffer = ByteArrayOutputStream()
|
||||
val chunk = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
val read = input.read(chunk)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > limit) return null
|
||||
buffer.write(chunk, 0, read)
|
||||
}
|
||||
return buffer.toByteArray()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 一枚附件芯片要显示什么(纯数据,UI 直接照着渲染)。
|
||||
*
|
||||
* 字段刻意是**已格式化好的字符串**而不是原始数值:气泡里没有第二处地方能解释这些数,
|
||||
* 而「12.0 KB」「8,000 字」这种换算一旦写进 Composable 就没法单测了。
|
||||
*/
|
||||
data class DocumentChipModel(
|
||||
/** 服务端存储名(`<32hex>.<ext>`)或待发附件的本地显示名。 */
|
||||
val name: String,
|
||||
/** 类型标签(扩展名大写);判不出类型时是「文件」。 */
|
||||
val typeLabel: String,
|
||||
/** 体积标签;读不出来时是空串。 */
|
||||
val sizeLabel: String,
|
||||
/** 本回合注入的字符数;服务端没报就是 `null`。 */
|
||||
val charsLabel: String?,
|
||||
/** 抽取状态;服务端没报就回 `null`,未知取值**原样显示**。 */
|
||||
val statusLabel: String?,
|
||||
/**
|
||||
* `truncated` 的标签:只表示**注入被预算截断**(正文照样进了上下文),
|
||||
* 与 [unreadable] 是两件事,文案也必须分开写。
|
||||
*/
|
||||
val truncatedLabel: String?,
|
||||
/** `status == "unreadable"`:这是唯一一个「昔涟读不到」的信号,UI 据此给灰态。 */
|
||||
val unreadable: Boolean,
|
||||
/**
|
||||
* 服务端「读不到」的**原话**(`documents[].reason`,契约 v0.63;只在 `unreadable` 时下发)。
|
||||
* 已去掉首尾空白,空白串与缺席一律归一成 `null`。
|
||||
*
|
||||
* ⚠️ **刻意不进 [detail]**:这句话有 40+ 字,塞进芯片那一行只会被 `Ellipsis` 切掉半句,
|
||||
* 比不显示更糟。它要出现的地方是**用户点开附件后的预览弹层**,见
|
||||
* [DocumentTarget.previewPlan]。
|
||||
*/
|
||||
val reason: String? = null,
|
||||
) {
|
||||
/** 芯片副行:`MD · 12 KB · 8,000 字 · 已读入`(缺哪项少哪项,全缺就是空串)。 */
|
||||
val detail: String
|
||||
get() = listOfNotNull(typeLabel, sizeLabel, charsLabel, statusLabel, truncatedLabel)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" · ")
|
||||
}
|
||||
|
||||
/** 附件芯片的展示换算(纯函数,与 UI 无关)。 */
|
||||
object DocumentChips {
|
||||
|
||||
/**
|
||||
* 一条**已落库**的文档引用 → 芯片模型。
|
||||
*
|
||||
* 名字只有服务端存储名可用:服务端按内容寻址落盘(`sha256[:32] + ext`),**原始文件名
|
||||
* 永不参与路径**,也就不会回到客户端。所以这里**不编**一个「用户当初叫它什么」的名字——
|
||||
* 那会是本端凭空捏造的信息(历史消息里根本没有它)。长名字由 UI 侧
|
||||
* `TextOverflow.MiddleEllipsis` 处理,保住扩展名。
|
||||
*/
|
||||
fun of(ref: DocumentRefDto): DocumentChipModel = DocumentChipModel(
|
||||
name = ref.name,
|
||||
typeLabel = typeLabel(ref.name),
|
||||
sizeLabel = sizeLabel(ref.size),
|
||||
charsLabel = charsLabel(ref.chars),
|
||||
statusLabel = statusLabel(ref.status),
|
||||
truncatedLabel = if (ref.truncated) TRUNCATED else null,
|
||||
unreadable = DocumentStatus.isUnreadable(ref.status),
|
||||
// 首尾空白去掉;只剩空白(或本就缺席)时归一成 null——「有一句空话」比没有更坏:
|
||||
// 弹层会拿它顶掉那句通用说明,于是用户看到一块什么都没有的正文区。
|
||||
reason = ref.reason?.trim()?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
|
||||
/** 一份**待发**附件 → 芯片模型:还没有服务端结论,所以状态与字数为空。 */
|
||||
fun of(draft: DraftDocument): DocumentChipModel = DocumentChipModel(
|
||||
name = draft.displayName.ifBlank { draft.uri },
|
||||
typeLabel = typeLabel(draft.displayName),
|
||||
sizeLabel = sizeLabel(draft.sizeBytes),
|
||||
charsLabel = null,
|
||||
statusLabel = null,
|
||||
truncatedLabel = null,
|
||||
unreadable = false,
|
||||
// 待发附件还没有任何服务端结论,也就没有「读不到的原因」可言。
|
||||
reason = null,
|
||||
)
|
||||
|
||||
/** 扩展名大写;没有扩展名或扩展名不像扩展名时回「文件」(不猜类型)。 */
|
||||
fun typeLabel(name: String): String {
|
||||
val extension = name.substringAfterLast('.', "")
|
||||
val usable = extension.length in 1..8 && extension.all { it.isLetterOrDigit() }
|
||||
return if (usable) extension.uppercase(Locale.ROOT) else NO_TYPE
|
||||
}
|
||||
|
||||
/** 体积;[DraftDocument.UNKNOWN_SIZE](拿不到元数据)回空串,**不显示一个假的 0 B**。 */
|
||||
fun sizeLabel(bytes: Long): String = when {
|
||||
bytes < 0 -> ""
|
||||
bytes < KIB -> "$bytes B"
|
||||
bytes < MIB -> oneDecimal(bytes / KIB.toDouble()) + " KB"
|
||||
else -> oneDecimal(bytes / MIB.toDouble()) + " MB"
|
||||
}
|
||||
|
||||
/** 注入字符数;`null`(服务端没报这一项)回 `null`。 */
|
||||
fun charsLabel(chars: Int?): String? =
|
||||
chars?.let { String.format(Locale.ROOT, "%,d", it) + " 字" }
|
||||
|
||||
/**
|
||||
* 抽取状态 → 文案。`""`(服务端未报)回 `null`,**未知取值原样显示**——
|
||||
* 与项目里 `mode` / `platform` / `mood` 的开放集合口径一致,不丢弃、不占位符。
|
||||
*
|
||||
* 三句分开写是刻意的:[LISTED] 只说明「正文没进上下文」,**不是**读不到;
|
||||
* [UNREADABLE] 才是读不到。
|
||||
*/
|
||||
fun statusLabel(status: String): String? = when (status) {
|
||||
"" -> null
|
||||
DocumentStatus.EXTRACTED -> "已读入"
|
||||
DocumentStatus.LISTED -> "只看得到清单"
|
||||
DocumentStatus.UNREADABLE -> "读不到"
|
||||
else -> status
|
||||
}
|
||||
|
||||
private fun oneDecimal(value: Double): String = String.format(Locale.ROOT, "%.1f", value)
|
||||
|
||||
private const val NO_TYPE = "文件"
|
||||
|
||||
private const val TRUNCATED = "注入已截断"
|
||||
|
||||
private const val KIB = 1024L
|
||||
|
||||
private const val MIB = 1024L * 1024L
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
|
||||
/**
|
||||
* 一份待上传的本地附件。
|
||||
*
|
||||
* 只带「服务端裁决需要的两样」:[fileName](**扩展名真的参与裁决**——服务端把白名单扩展名与
|
||||
* 字节嗅探**合取**,两者都过才放行;其余部分会被丢进内容寻址的存储名里)与 [bytes]。
|
||||
*
|
||||
* 不用 `data class`:`ByteArray` 的 `equals` 是引用比较,写成 `data class` 会给出一个
|
||||
* 「看着像值语义、其实永远不等」的陷阱(测试里第一眼就会撞上)。
|
||||
*/
|
||||
class DocumentFile(
|
||||
val fileName: String,
|
||||
val bytes: ByteArray,
|
||||
) {
|
||||
val sizeBytes: Long get() = bytes.size.toLong()
|
||||
}
|
||||
|
||||
/** 正文该内联发出,还是该先落成文档。 */
|
||||
sealed interface DocumentPlan {
|
||||
|
||||
/** 照既有路由([ChatSendRouting])内联发出:没有附件,正文也没到「文件级」。 */
|
||||
data object Inline : DocumentPlan
|
||||
|
||||
/**
|
||||
* 先 `POST /api/v1/documents` 换成存储名,再用 `chat.documents` 引用发出。
|
||||
* 调用方应把这一路整个交给 REST(见类型 KDoc 第 2 条的策略说明),不要在 WS 上重开一条。
|
||||
*
|
||||
* @param files 本地附件(≤ [DocumentDelivery.MAX_ATTACHMENTS_PER_TURN] 份)。
|
||||
* @param textAsDocument 正文是否也要落成一份 `.txt` 一起当附件发。
|
||||
*/
|
||||
data class AsDocuments(
|
||||
val files: List<DocumentFile> = emptyList(),
|
||||
val textAsDocument: Boolean = false,
|
||||
) : DocumentPlan
|
||||
|
||||
/**
|
||||
* 明确拒绝:调用方**必须**给用户一个说法,**绝不静默丢**。
|
||||
* 「静默丢」正是本模块要消灭的东西——一方以为附件发上去了、另一方一无所知。
|
||||
*/
|
||||
data class Rejected(val reason: DocumentRejection) : DocumentPlan
|
||||
}
|
||||
|
||||
/** 本地就能判定的拒绝理由(服务端的同类拒绝见 [DocumentFailure])。 */
|
||||
enum class DocumentRejection {
|
||||
/** 一个回合的附件数超过上限:不做「只发前 5 个」这种裁剪,多出来的必须让用户看见。 */
|
||||
TooManyAttachments,
|
||||
}
|
||||
|
||||
/**
|
||||
* 「**从别的 App 分享进来**的长正文」该怎么送(纯函数,见 [DocumentDelivery.planSharedText])。
|
||||
*
|
||||
* 只用于分享那条路。用户自己在输入框里打的字**永远**是 [Inline]——「不做长正文自动转文件」是
|
||||
* 已拍板的产品定案(`devdocs/05` 的「三条待拍板项的定案」第 3 条),本枚举不改变它一个字。
|
||||
*/
|
||||
enum class SharedTextPlan {
|
||||
|
||||
/** 正文照正文发(没到 [DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT],或附件位不够,见下一档)。 */
|
||||
Inline,
|
||||
|
||||
/**
|
||||
* 正文落成一份 `.txt` 附件:上传 `POST /api/v1/documents` 之后随 `chat.documents` 发,
|
||||
* **正文那一栏留空**。
|
||||
*/
|
||||
AsDocument,
|
||||
|
||||
/**
|
||||
* 正文长到该走文件,但**附件位已经被分享进来的文件占满** ⇒ 只能按正文发。
|
||||
*
|
||||
* 调用方**必须**如实告知(分享那条路从不静默改形态)。取这一档而不是「把 .txt 塞进去、
|
||||
* 挤掉一个用户真的分享进来的文件」:那些文件是用户**明确**要发的东西,正文换形态是**为了
|
||||
* 少占篇幅**的便利——为了便利牺牲用户指名要发的内容,是不划算的交换。
|
||||
*/
|
||||
InlineAttachmentsFull,
|
||||
}
|
||||
|
||||
/**
|
||||
* 「该不该走文件」的判定与 `chat.documents` 载荷构造(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么这条规则值得单独存在
|
||||
* 三条互不相同的约束在客户端汇成一个决定,任何一条被忘掉都会出线上事故:
|
||||
*
|
||||
* 1. **WS 帧 4096 字节硬上限,且超限是直接断连**(不是回错误)⇒ 正文超过它必须走 REST
|
||||
* —— 这一步**已经在** [ChatSendRouting] 里,本对象**不另立阈值**,只用 [textNeedsRest]
|
||||
* 把它显式暴露出来,免得接线时把「超帧限」误当成「该转文件」;
|
||||
* 2. **文档一律走 REST**(回执五 §一 S3-B 的明文规则):文档本体经
|
||||
* `POST /api/v1/documents` 上传、引用随 `POST /api/v1/chat` 发出。实测后端 WS `chat` 帧
|
||||
* **确实**能收 `documents`(`ws_hub_turn.go` 的入站结构与 REST 同形),所以这一条是
|
||||
* **客户端策略**而非后端限制——理由是把「上传 → 引用 → 起回合」压在**一条**通道上,
|
||||
* 不让两条入口在错误面(413 / 415 / 404 / too_many)上分叉。所以
|
||||
* [DocumentPlan.AsDocuments] 的调用方不该再把这条消息交给 WS;
|
||||
* 3. **长正文按文件送,比内联更划算**:内联的正文会进**每一页历史**、每一次 `history` 回放、
|
||||
* 以及这一轮的完整上下文;落成文档后历史里只留一条引用(名 / MIME / 大小 / 字符数 /
|
||||
* 状态),正文按预算注入、还能被 `document-read` 按游标**继续读**。
|
||||
*
|
||||
* ## 阈值与它们各自的分量(**别把三个数混成一个**)
|
||||
* | 界线 | 值 | 谁定的 | 越过的后果 |
|
||||
* |---|---|---|---|
|
||||
* | WS 帧上限 | 4096 字节 | 服务端 `wsReadLimit` | **直接断连**(不是报错)⇒ 走 REST |
|
||||
* | 走 REST 的正文上限 | 3584 字节 | 本端预留 512 字节 JSON 外壳 | 就是「换条通道」,无惩罚 |
|
||||
* | **转文件的正文界线** | 4000 **rune** | **本端自定**(见下) | 换形态:正文变附件 |
|
||||
*
|
||||
* ⚠️ **第三行是客户端的产品判断,不是服务端拒绝线。** 契约里唯一的 4000 rune 上限是
|
||||
* `PATCH /api/v1/messages/{id}` 的**编辑正文**上限(服务端 `maxMessageContentRunes`),
|
||||
* **`POST /api/v1/chat` 根本没有正文长度上限**(已核 `api-client.md` §1 行 3 与后端
|
||||
* `rest.go` / `ws_hub_turn.go` 的请求结构)。这里借它当「正文量级已经不像一条消息」的
|
||||
* 参照——按 Unicode 码点计,与服务端 Go 的 rune 口径一致(代理对算一个)。
|
||||
* 于是 [textNeedsFile] 只是**建议**:[plan] 会据此给出 [DocumentPlan.AsDocuments],
|
||||
* 而**接线时要不要真的自动转换,是产品取舍**(建议先问用户:「这段内容较长,
|
||||
* 作为文件发送?」),纯函数不替产品做决定、也不静默改变既有行为。
|
||||
*
|
||||
* 反过来,**正文只是超过 4096 字节(走 REST 就够)不该被当成文件**:那会平白改变一条
|
||||
* 已经验证过的路径(长文本 → REST 内联)的语义,而 REST 本来装得下。
|
||||
*/
|
||||
object DocumentDelivery {
|
||||
|
||||
/** 一个回合的附件数上限:服务端 `maxChatDocuments = 5`(回执五 §一 S3-B),含等于。 */
|
||||
const val MAX_ATTACHMENTS_PER_TURN: Int = 5
|
||||
|
||||
/**
|
||||
* 正文「已经不像一条消息」的**客户端**界线(rune,含等于 → 仍算一条消息)。
|
||||
*
|
||||
* 取值参照契约里唯一的 rune 计数上限——`PATCH /api/v1/messages/{id}` 的 4000 rune
|
||||
* (见类型 KDoc 的诚实说明:`/chat` 没有这条上限)。
|
||||
*/
|
||||
const val TEXT_AS_DOCUMENT_RUNE_LIMIT: Int = 4000
|
||||
|
||||
/** 长正文落成附件时的扩展名:服务端白名单内的 `.txt`(名字其余部分会被内容寻址丢弃)。 */
|
||||
const val TEXT_DOCUMENT_FILE_NAME: String = "message.txt"
|
||||
|
||||
/** 按 Unicode 码点计数,与服务端 Go 的 rune 口径一致(代理对算一个)。 */
|
||||
fun runeCount(text: String): Int = text.codePointCount(0, text.length)
|
||||
|
||||
/** 正文是否已经超过「一条消息的正文量级」⇒ 建议按文件发送。见类型 KDoc 的界线说明。 */
|
||||
fun textNeedsFile(text: String): Boolean = runeCount(text) > TEXT_AS_DOCUMENT_RUNE_LIMIT
|
||||
|
||||
/**
|
||||
* 正文是否连既有的 WS 通道都放不下 ⇒ 必须走 REST。
|
||||
*
|
||||
* 直接复用 [ChatSendRouting] 的判据(**不另立阈值**):这里只是把
|
||||
* 「超帧限 = 换通道」与「超 rune 界线 = 换形态」两件事在名字上分开。
|
||||
*/
|
||||
fun textNeedsRest(text: String): Boolean =
|
||||
ChatSendRouting.channelFor(text, hasImage = false) == SendChannel.Rest
|
||||
|
||||
/**
|
||||
* 这一轮该怎么发。
|
||||
*
|
||||
* 判定顺序(先拒绝、再看附件、最后看正文):
|
||||
* 1. 附件 > [MAX_ATTACHMENTS_PER_TURN] → [DocumentPlan.Rejected](**不裁剪**);
|
||||
* 2. 有附件 → [DocumentPlan.AsDocuments](附件一律先上传换存储名);
|
||||
* 3. 没附件、正文超 [TEXT_AS_DOCUMENT_RUNE_LIMIT] → [DocumentPlan.AsDocuments](正文落成 `.txt`);
|
||||
* 4. 其余 → [DocumentPlan.Inline]。
|
||||
*
|
||||
* @param text 本轮的正文(调用方已 trim;空串 = 纯附件回合,服务端**支持**纯附件回合)。
|
||||
* @param files 本轮要挂的本地附件。
|
||||
*/
|
||||
fun plan(text: String, files: List<DocumentFile>): DocumentPlan =
|
||||
when (val shape = planForCount(text, files.size)) {
|
||||
is DocumentPlan.AsDocuments -> shape.copy(files = files)
|
||||
else -> shape
|
||||
}
|
||||
|
||||
/**
|
||||
* 同 [plan] 的判定矩阵,但**只看附件份数**。
|
||||
*
|
||||
* 为什么需要它:调用方(`ChatViewModel`)手上还没有字节——最多 5×20 MiB 一次性读进内存
|
||||
* 是不可接受的,而判定矩阵里**唯一**与文件内容有关的就是份数(正文那一项看 [textNeedsFile])。
|
||||
* 有了这一条,调用方就能在读字节之前先答出「要不要拒绝」「要不要走上传这条链」,
|
||||
* 而不是先读一堆字节再发现自己根本不该发。
|
||||
*
|
||||
* @param attachmentCount 本回合要挂的附件份数。
|
||||
*/
|
||||
fun planForCount(text: String, attachmentCount: Int): DocumentPlan = when {
|
||||
attachmentCount > MAX_ATTACHMENTS_PER_TURN -> DocumentPlan.Rejected(DocumentRejection.TooManyAttachments)
|
||||
attachmentCount > 0 -> DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = textNeedsFile(text))
|
||||
textNeedsFile(text) -> DocumentPlan.AsDocuments(textAsDocument = true)
|
||||
else -> DocumentPlan.Inline
|
||||
}
|
||||
|
||||
/**
|
||||
* 把长正文落成一份待上传的 `.txt`(服务端只看扩展名,名字其余部分会被丢弃)。
|
||||
* 字节就是 UTF-8 编码——服务端嗅探得到的基类型因此是 `text/plain`。
|
||||
*/
|
||||
fun textAsFile(text: String, fileName: String = TEXT_DOCUMENT_FILE_NAME): DocumentFile =
|
||||
DocumentFile(fileName = fileName, bytes = text.toByteArray(Charsets.UTF_8))
|
||||
|
||||
/**
|
||||
* **分享路径**专用:这一轮的正文该怎么送([SharedTextPlan])。
|
||||
*
|
||||
* 为什么分享这条路要转、而输入框那条不转(两件事**不是**同一件):
|
||||
* 分享进来的长正文往往是**别的平台把卡片/文章摊平之后的一整段字**(主人原话:「有的时候部分平台
|
||||
* 发送到非官方支持卡片的平台会将卡片内容转化为比较长的字符」)——它不是用户抱着键盘敲出来的
|
||||
* 一句话,而是一份**搬运进来的内容**,以文本消息发出去会把会话拉得很长。用户自己打的字则相反:
|
||||
* 那是他要说的话,悄悄换成附件只会让它在历史里搜不到、气泡变成一枚芯片(`devdocs/05` 的定案)。
|
||||
*
|
||||
* 判据两步,都只有纯值输入:
|
||||
* 1. 正文没到 [textNeedsFile] ⇒ [SharedTextPlan.Inline];
|
||||
* 2. 到了、但附件位已经满([MAX_ATTACHMENTS_PER_TURN])⇒ [SharedTextPlan.InlineAttachmentsFull]
|
||||
* (**如实告知**由调用方负责);否则 ⇒ [SharedTextPlan.AsDocument]。
|
||||
*
|
||||
* @param text 分享进来的正文(调用方已 trim)。
|
||||
* @param attachmentCount 这一轮**已经**要挂的附件份数(分享进来的文件,不含 .txt 本身)。
|
||||
*/
|
||||
fun planSharedText(text: String, attachmentCount: Int): SharedTextPlan = when {
|
||||
!textNeedsFile(text) -> SharedTextPlan.Inline
|
||||
attachmentCount >= MAX_ATTACHMENTS_PER_TURN -> SharedTextPlan.InlineAttachmentsFull
|
||||
else -> SharedTextPlan.AsDocument
|
||||
}
|
||||
|
||||
/**
|
||||
* 由**已上传**的服务端存储名构造 `chat.documents` 载荷。
|
||||
*
|
||||
* 只带 `name`(见 [DocumentRefDto]:服务端不采信其它字段并会覆盖),且**≤5 条**。
|
||||
*
|
||||
* @return `null` = 这份载荷**不合法**(超过 5 条,或有空名字),调用方**必须**给用户提示,
|
||||
* **不得**截断到 5 条、也**不得**丢掉空名字那条——服务端对>5 回 `400 too_many_documents`、
|
||||
* 对非法/不存在的名字回 `404 document_not_found`,两边都不做静默裁剪。
|
||||
*/
|
||||
fun payload(storageNames: List<String>): List<DocumentRefDto>? = when {
|
||||
storageNames.size > MAX_ATTACHMENTS_PER_TURN -> null
|
||||
storageNames.any { it.isBlank() } -> null
|
||||
else -> storageNames.map { DocumentRefDto(name = it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
|
||||
/**
|
||||
* 文档操作失败(上传 / 取值)。契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**,错误码依据见
|
||||
* `02-前后端协同事项.md` 回执五 §一 S1 与 §五「v0.59 会登记什么」。
|
||||
*
|
||||
* ## 为什么要把错误码收成类型
|
||||
* 四个码在**用户能做的事**上各不相同,折进一个「操作失败」会让 UI 只能靠字符串猜:
|
||||
*
|
||||
* | 码 | HTTP | 用户能做什么 |
|
||||
* |---|---|---|
|
||||
* | [UnsupportedType] `unsupported_media_type` | 415 | **换文件**:这个格式不在白名单里(deny-by-default 的文本/代码扩展名,含 `.js` / `.py` / `.sh` 一律拒),改名 / 改声明都没有用——**字节嗅探是唯一裁决** |
|
||||
* | [TooLarge] `document_too_large` | 413 | **换文件**:超过单文件上限(默认 20 MiB,可部署配置) |
|
||||
* | [TooMany] `too_many_documents` | 400 | **少选几个**:一个回合最多 5 个附件 |
|
||||
* | [NotFound] `document_not_found` | 404 | 重传:存储名形态非法或文件已不在(非法与不存在**刻意同码**,不给探测面) |
|
||||
* | [Unreadable] `document_unreadable` | 400 | **只有显式索取正文时才会出现**(如 `document-read`),不是「上传失败」 |
|
||||
*
|
||||
* 每个分支都带 [message]:四种拒绝的文案由**服务端产出**(含真实上限),客户端原样展示、
|
||||
* 不自己编数字(与 `image_too_large` 同一口径)。未知码与本地网络错误一律落 [Other],
|
||||
* **原样带上服务端消息**,绝不因为「不认识这个码」就把失败吞掉。
|
||||
*/
|
||||
sealed interface DocumentFailure {
|
||||
|
||||
/** 可直接展示给用户的文案(服务端有话说时就是它说的那句)。 */
|
||||
val message: String
|
||||
|
||||
/** `415 unsupported_media_type`:扩展名白名单外,或字节嗅探判出非文本。 */
|
||||
data class UnsupportedType(override val message: String) : DocumentFailure
|
||||
|
||||
/** `413 document_too_large`:超过单文件字节上限(`CHAT_DOCUMENT_MAX_BYTES`,默认 20 MiB)。 */
|
||||
data class TooLarge(override val message: String) : DocumentFailure
|
||||
|
||||
/** `400 too_many_documents`:一个回合挂的附件数超过 5。 */
|
||||
data class TooMany(override val message: String) : DocumentFailure
|
||||
|
||||
/** `404 document_not_found`:存储名非法或文件不存在(两者同码)。 */
|
||||
data class NotFound(override val message: String) : DocumentFailure
|
||||
|
||||
/** `400 document_unreadable`:正文取不回来(**仅显式索取正文时**)。 */
|
||||
data class Unreadable(override val message: String) : DocumentFailure
|
||||
|
||||
/** 401 / 403:会话失效或角色不够(文档端点要求 `owner|admin`)。 */
|
||||
data object Forbidden : DocumentFailure {
|
||||
override val message: String get() = "登录状态已失效,请重新登录"
|
||||
}
|
||||
|
||||
/** 其它失败:网络 / 超时 / 未知码 / 服务端 5xx——消息照原样带出来。 */
|
||||
data class Other(override val message: String) : DocumentFailure
|
||||
}
|
||||
|
||||
/** 把仓库层的 `Result` 失败收敛成 [DocumentFailure]。 */
|
||||
fun Throwable.toDocumentFailure(): DocumentFailure {
|
||||
val error = (this as? CyreneException)?.error
|
||||
return when {
|
||||
error is CyreneError.Unauthorized -> DocumentFailure.Forbidden
|
||||
error is CyreneError.Server && error.httpStatus == 403 -> DocumentFailure.Forbidden
|
||||
error is CyreneError.Server -> when (error.code) {
|
||||
"unsupported_media_type" -> DocumentFailure.UnsupportedType(error.userMessage)
|
||||
"document_too_large" -> DocumentFailure.TooLarge(error.userMessage)
|
||||
"too_many_documents" -> DocumentFailure.TooMany(error.userMessage)
|
||||
"document_not_found" -> DocumentFailure.NotFound(error.userMessage)
|
||||
"document_unreadable" -> DocumentFailure.Unreadable(error.userMessage)
|
||||
else -> DocumentFailure.Other(error.userMessage)
|
||||
}
|
||||
|
||||
error != null -> DocumentFailure.Other(error.userMessage)
|
||||
else -> DocumentFailure.Other("文档操作失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 预览 / 打开 / 保存要处理的那一份附件**从哪来**。
|
||||
*
|
||||
* 判定只发生在一处([DocumentTarget.of]),预览取字节、交给别的 App、保存到下载目录三条路
|
||||
* 都从这里取来源——散成两处就会出现「预览读的是服务端、打开读的是本地」这种半截行为。
|
||||
*
|
||||
* 两种来源是**真的事实差异**,不是实现细节:已落库的附件只有服务端存储名(消息 DTO 刻意不带
|
||||
* 正文,见契约 v0.59 §1.2 ④),还没发出去的待发附件**只有**本地 URI(那时还没有存储名)。
|
||||
*/
|
||||
sealed interface DocumentSource {
|
||||
|
||||
/**
|
||||
* 已落库消息里的附件:字节要按服务端存储名现拉(`GET /api/v1/documents/{name}`,要 Bearer)。
|
||||
*
|
||||
* [mime] 是 DTO 里那个**字节嗅探**所得的基类型——「用其它应用打开」就用它,
|
||||
* 不从 [name] 的扩展名猜(见 [DocumentOpenTypes.forServer])。
|
||||
*/
|
||||
data class Server(val name: String, val mime: String) : DocumentSource
|
||||
|
||||
/** 待发附件:字节在本地 URI 上(SAF 的 `content://` 或分享落地后的 `file://`)。 */
|
||||
data class Local(val uri: String, val displayName: String) : DocumentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* 被点开的那一枚附件芯片(纯数据,UI 照着它渲染预览弹层)。
|
||||
*
|
||||
* [chips] 就是气泡里那枚芯片的模型([DocumentChips]),**刻意复用**:弹层再写一套
|
||||
* 「类型 · 体积 · 字数 · 状态」的标签,两边迟早会有一处漏改状态语义。
|
||||
*/
|
||||
data class DocumentTarget(
|
||||
val source: DocumentSource,
|
||||
val chips: DocumentChipModel,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 「用其它应用打开」要声明的类型。
|
||||
*
|
||||
* 服务端来源用 DTO 里嗅探所得的 [DocumentSource.Server.mime];本地来源只能来自平台声明
|
||||
* (`ContentResolver.getType`,由调用方传进来)。**两条路都不从扩展名猜**:服务端的
|
||||
* `data.json` 嗅探回的是 `text/plain`,按扩展名猜成 `application/json` 等于替服务端
|
||||
* 说一个它刻意不承认的类型。
|
||||
*/
|
||||
fun openType(declaredLocalType: String? = null): String = when (val origin = source) {
|
||||
is DocumentSource.Server -> DocumentOpenTypes.forServer(origin.mime)
|
||||
is DocumentSource.Local -> DocumentOpenTypes.forLocal(declaredLocalType)
|
||||
}
|
||||
|
||||
/**
|
||||
* App 内**正文区**该怎么渲染([DocumentPreviewGate] 的裁决,纯函数)。
|
||||
*
|
||||
* 与 [openType] 一起把「哪个字段才是真源」的知识留在这一个类里:服务端来源用 DTO 的
|
||||
* `mime`,本地待发附件**没有**服务端 `mime`(那时还没上传过),只能按显示名兜底。
|
||||
* 本地那条**刻意不问** `ContentResolver.getType`——平台给 `.rs` 这类文件常报
|
||||
* `application/octet-stream` 或空,拿它当判据会把一批纯文本文件挡在预览之外。
|
||||
*
|
||||
* 服务端下发的「读不到的原因」([DocumentChipModel.reason],契约 v0.63)在这里**顺路带进
|
||||
* 裁决**:它是这句话唯一的去处(芯片那一行放不下 40+ 字),详见 [DocumentPreviewGate.plan]。
|
||||
*/
|
||||
fun previewPlan(): DocumentPreviewPlan = when (val origin = source) {
|
||||
is DocumentSource.Server -> DocumentPreviewGate.plan(origin.mime, origin.name, chips.reason)
|
||||
is DocumentSource.Local -> DocumentPreviewGate.plan("", origin.displayName, chips.reason)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到下载目录时建议的文件名。
|
||||
*
|
||||
* ⚠️ **服务端来源只能用存储名**:落盘名是 `sha256(bytes)[:32] + ext`(内容寻址),
|
||||
* 原始文件名**永不参与路径**、也不回客户端——DTO 里根本没有它。所以这里如实给
|
||||
* `<32hex>.<ext>`,**绝不**替用户编一个「用户当初叫它什么」的假名字(那会是本端凭空的
|
||||
* 信息)。本地待发附件倒是还有用户当初选的那份显示名。
|
||||
*/
|
||||
fun suggestedFileName(): String = when (val origin = source) {
|
||||
is DocumentSource.Server -> origin.name
|
||||
is DocumentSource.Local -> origin.displayName.ifBlank { FALLBACK_FILE_NAME }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/** 已落库消息的附件引用 → 目标(来源恒为服务端)。 */
|
||||
fun of(ref: DocumentRefDto): DocumentTarget =
|
||||
DocumentTarget(DocumentSource.Server(ref.name, ref.mime), DocumentChips.of(ref))
|
||||
|
||||
/** 待发附件 → 目标(来源恒为本地)。 */
|
||||
fun of(draft: DraftDocument): DocumentTarget =
|
||||
DocumentTarget(DocumentSource.Local(draft.uri, draft.displayName), DocumentChips.of(draft))
|
||||
}
|
||||
}
|
||||
|
||||
private const val FALLBACK_FILE_NAME = "document.txt"
|
||||
|
||||
/**
|
||||
* 「用其它应用打开」时 Intent 上要声明的 MIME。
|
||||
*
|
||||
* 与 `ChatScreen` 选文件时那份 [DocumentPickerMimeTypes] 相反:那是「让用户选得到」的宽口径,
|
||||
* 这里是「交给别的 App 时声明它是什么」——**只有服务端嗅探(或平台声明)这一个真源**,
|
||||
* 判不出来宁可用 `application/octet-stream`,也不按扩展名猜。
|
||||
*/
|
||||
object DocumentOpenTypes {
|
||||
|
||||
const val OCTET_STREAM: String = "application/octet-stream"
|
||||
|
||||
/** 服务端文档:DTO 的 `mime` 是嗅探所得基类型(唯一裁决);服务端嗅不出类型时也下发 `octet-stream`。 */
|
||||
fun forServer(mime: String): String = mime.trim().ifBlank { OCTET_STREAM }
|
||||
|
||||
/** 本地待发文件:类型只可能来自平台声明;拿不到就回 `octet-stream`(不猜扩展名)。 */
|
||||
fun forLocal(declared: String?): String = declared?.trim()?.takeIf { it.isNotEmpty() } ?: OCTET_STREAM
|
||||
}
|
||||
|
||||
/** 「用其它应用打开」这一步的裁决结果。 */
|
||||
sealed interface DocumentOpenPlan {
|
||||
|
||||
/** 系统里有 App 能处理这个类型:把 Intent 交出去。 */
|
||||
data object HandOff : DocumentOpenPlan
|
||||
|
||||
/**
|
||||
* 没有任何 App 能打开这个类型:**必须如实告知**,并指路「保存到下载目录」。
|
||||
*
|
||||
* 静默什么也不发生是最坏的结果——用户按了按钮,界面纹丝不动,他既不知道失败在哪,
|
||||
* 也不知道还有别的出路。这条分支因此带一句现成的话,而不是一个光秃秃的布尔。
|
||||
*/
|
||||
data class NoHandler(val message: String) : DocumentOpenPlan
|
||||
}
|
||||
|
||||
/** 「用其它应用打开」的裁决(纯函数:`PackageManager` 只回答 [plan] 的 `hasHandler` 那一问)。 */
|
||||
object DocumentOpen {
|
||||
|
||||
/**
|
||||
* @param type 交给 Intent 的 MIME([DocumentOpenTypes] 的产物)。
|
||||
* @param hasHandler 系统里是否有 App 能处理 `ACTION_VIEW + type`(UI 层查 `PackageManager`)。
|
||||
*/
|
||||
fun plan(type: String, hasHandler: Boolean): DocumentOpenPlan =
|
||||
if (hasHandler) DocumentOpenPlan.HandOff else DocumentOpenPlan.NoHandler(noHandlerMessage(type))
|
||||
|
||||
/**
|
||||
* 没有任何 App 能处理 [type] 时的那句话。
|
||||
*
|
||||
* 单独暴露一个入口是因为「解析得到 handler」与「真的启动起来」之间还有一段窗口(App 被
|
||||
* 卸载 / 被停用)——那条 `ActivityNotFoundException` 分支要说的是**同一句话**,不能在那里
|
||||
* 另写一份文案(两份迟早会分叉)。
|
||||
*/
|
||||
fun noHandlerMessage(type: String): String =
|
||||
"没有应用能打开「$type」格式的文件,可以「保存到下载目录」后再处理"
|
||||
}
|
||||
|
||||
/**
|
||||
* App 内**正文区**能不能按文本渲染。
|
||||
*
|
||||
* 弹层的三条出口与「正文区」是两件事:判成 [NotText] 时弹层**照常打开**(文件名 · 体积 ·
|
||||
* 字数 · 状态与两条出口都在),只是**正文区留空** + 一句人话。
|
||||
*/
|
||||
sealed interface DocumentPreviewPlan {
|
||||
|
||||
/** 文本类:正文按 [DocumentPreview.slice] 的结果渲染。 */
|
||||
data object Text : DocumentPreviewPlan
|
||||
|
||||
/**
|
||||
* 非文本类(PDF 这类二进制内容):**绝不把字节当 UTF-8 渲染**(那会是一整屏乱码),
|
||||
* 也**不假装加载失败**(这不是失败,字节取回来了,只是本端不预览它)。
|
||||
*
|
||||
* [message] 有两种来源,都是**原样显示**:本端按类型写的那句([DocumentPreviewGate.refusalMessage]),
|
||||
* 或服务端下发的「读不到的原因」原话(契约 v0.63 的 `documents[].reason`)。
|
||||
*/
|
||||
data class NotText(val message: String) : DocumentPreviewPlan
|
||||
}
|
||||
|
||||
/**
|
||||
* 「App 内文本预览」这道闸(纯函数,可单测)。
|
||||
*
|
||||
* ## 为什么要有这道闸
|
||||
* W11 的预览是把字节当 UTF-8 文本渲染的——那时白名单里的附件**除了 `.pdf` 之外全是文本类**
|
||||
* (`.pdf` 正是 `v0.61` 才新增的第 23 项),所以「取回字节 → 解码 → 显示」在那些附件上是成立的。
|
||||
* 纳入 `.pdf` 之后,同一段代码会把一份 PDF 渲染成一整屏二进制乱码:看着像文件坏了,
|
||||
* 实际是本端拿错了渲染方式。
|
||||
*
|
||||
* ## 判据(优先服务端,退而扩展名)
|
||||
* 0. `reason` 非空白 ⇒ **直接就是正文区那句说明,原样显示**(契约 v0.63:服务端说读不到时
|
||||
* 下发的原话,比本端按类型编的那句准确)。它压过下面三条,代价见 [plan] 的 KDoc;
|
||||
* 1. `mime` 非空 ⇒ **只看服务端那一次字节嗅探**(契约 §1.2:嗅探是唯一裁决权):
|
||||
* 以 `text/` 开头的判为文本,其余(`application/pdf` / `application/octet-stream` / 图片型 …)
|
||||
* 一律判为**非文本**;
|
||||
* 2. `mime` 缺失 / 空白 ⇒ 按**扩展名**兜底:白名单 23 项里除 `.pdf` 外都是文本类,所以只有
|
||||
* `.pdf` 判为非文本(这条兜底是给**待发附件**用的——它只有本地 URI 与显示名,没有服务端结论);
|
||||
* 3. 未知 `mime`(既不是 `text/` 那一族、也不是认识的类型)⇒ **判为非文本**。两侧的代价不对称:
|
||||
* 误判成非文本只是少一次预览(两条出口照旧完整可用),误判成文本却正是这道闸要消灭的乱码。
|
||||
*
|
||||
* 大小写不敏感,且**剥掉参数**(`text/plain; charset=utf-8` 的基类型是 `text/plain`)——
|
||||
* 带参数的 Content-Type 是常态,不该因为多了一截 `; charset=…` 就整份附件不给预览。
|
||||
*/
|
||||
object DocumentPreviewGate {
|
||||
|
||||
/** `.pdf` 的类型标签:写进那句人话里(用户认得的是「PDF」而不是 `application/pdf`)。 */
|
||||
const val PDF_LABEL: String = "PDF"
|
||||
|
||||
/**
|
||||
* @param mime 服务端 DTO 里的 `mime`(嗅探所得基类型);待发附件传空串。
|
||||
* @param name 服务端存储名或待发附件的显示名——**只在 [mime] 为空时才看它**。
|
||||
* @param reason 服务端下发的「读不到的原因」原话(`documents[].reason`,契约 v0.63;
|
||||
* 已由 [top.yeij.cyrene.domain.chat.DocumentChips] 去掉首尾空白并在空白时归一成 `null`),
|
||||
* 缺席 = 服务端没给,或这条引用本来就不是 `unreadable`。
|
||||
*
|
||||
* [reason] 非空白时**它直接就是正文区那句说明,原样显示**,不再走本端按 [mime] / 扩展名的
|
||||
* 裁决——理由是这句话已经断言了「无法抽取正文」:
|
||||
* - 它比本端那句通用说明([refusalMessage]:只知道类型,不知道到底为什么)**准确**,
|
||||
* 替它改写或归纳就是丢掉服务端唯一一次说清原因的机会;
|
||||
* - 它也不该与正文并存:服务端说正文抽不出来,本端再把字节当 UTF-8 渲染,产出的正是这道
|
||||
* 闸要消灭的「看着像文件坏了」。
|
||||
*
|
||||
* 代价说清楚:这条会让**服务端判为读不到、但字节其实下得动**的附件失去 App 内预览
|
||||
* (例如超限的纯文本)。两条出口(用其它应用打开 / 保存到下载目录)照旧完整可用,
|
||||
* 与下面「未知 mime 判为非文本」是同一笔不对称账。
|
||||
*/
|
||||
fun plan(mime: String, name: String, reason: String? = null): DocumentPreviewPlan {
|
||||
if (!reason.isNullOrBlank()) return DocumentPreviewPlan.NotText(reason)
|
||||
val base = baseType(mime)
|
||||
if (base.isEmpty()) return extensionPlan(name)
|
||||
return if (base.startsWith(TEXT_PREFIX)) DocumentPreviewPlan.Text else refuse(base)
|
||||
}
|
||||
|
||||
/** 非文本那句人话。**说清「不是失败」**(两下都在弹层里摆着)并指路能拿到完整内容的两条出口。 */
|
||||
fun refusalMessage(label: String): String =
|
||||
"这是 $label,App 内不预览这类内容,请用「用其它应用打开」或「保存到下载目录」"
|
||||
|
||||
/** 去掉参数、去空白、转小写:`"text/plain; charset=utf-8"` → `"text/plain"`。 */
|
||||
fun baseType(mime: String): String =
|
||||
mime.substringBefore(';').trim().lowercase(Locale.ROOT)
|
||||
|
||||
/** 扩展名(小写、无点);没有扩展名时回空串。 */
|
||||
private fun extensionOf(name: String): String =
|
||||
name.substringAfterLast('.', "").trim().lowercase(Locale.ROOT)
|
||||
|
||||
private fun extensionPlan(name: String): DocumentPreviewPlan =
|
||||
if (extensionOf(name) == PDF_EXTENSION) refuse(PDF_LABEL) else DocumentPreviewPlan.Text
|
||||
|
||||
private fun refuse(baseType: String): DocumentPreviewPlan {
|
||||
// 已知是 PDF 就报「PDF」(用户认得这个名字),未知类型**原样报 mime**——绝不替服务端
|
||||
// 编一个更好听的类型名。
|
||||
val label = if (baseType == PDF_MIME) PDF_LABEL else baseType
|
||||
return DocumentPreviewPlan.NotText(refusalMessage(label))
|
||||
}
|
||||
|
||||
private const val TEXT_PREFIX = "text/"
|
||||
|
||||
private const val PDF_MIME = "application/pdf"
|
||||
|
||||
private const val PDF_EXTENSION = "pdf"
|
||||
}
|
||||
|
||||
/** 预览的截断阈值(两个上限,**先到者为准**)。 */
|
||||
object DocumentPreviewLimits {
|
||||
|
||||
/**
|
||||
* 单次预览最多取前 256 KB 字节。
|
||||
*
|
||||
* 取值理由:附件上限是 20 MiB(`CHAT_DOCUMENT_MAX_BYTES`),一份 20 MiB 的文本全塞进单个
|
||||
* `Text` 会让测量 / 布局在打开弹层那一刻卡住。256 KB 足够看清开头、翻不到底的都在下一条
|
||||
* 出口(「用其它应用打开」/「保存」)——那两条给的是**完整**内容。
|
||||
*/
|
||||
const val MAX_BYTES: Int = 256 * 1024
|
||||
|
||||
/** 行数上限:与字节上限先到者为准(19 万行的短行文件在 256 KB 内也能有 10 万行)。 */
|
||||
const val MAX_LINES: Int = 5_000
|
||||
|
||||
/**
|
||||
* 正文分块的粒度(行)。
|
||||
*
|
||||
* [MAX_LINES] 5000 行如果是一个 `Text`,一次测量就要算整段排版;切成 200 行一块交给
|
||||
* `LazyColumn`,只测当前可见的几块(最多 25 块)。
|
||||
*/
|
||||
const val LINES_PER_CHUNK: Int = 200
|
||||
}
|
||||
|
||||
/** 预览为什么只显示了一段([None] = 完整显示)。 */
|
||||
enum class PreviewTruncation {
|
||||
|
||||
None,
|
||||
|
||||
/** 撞上 [DocumentPreviewLimits.MAX_BYTES]。 */
|
||||
Bytes,
|
||||
|
||||
/** 撞上 [DocumentPreviewLimits.MAX_LINES]。 */
|
||||
Lines,
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览正文的一段切片。
|
||||
*
|
||||
* [shownBytes] / [shownLines] 是**实际显示了多少**,[totalBytes] / [totalLines] 是全文多少。
|
||||
* 截断说明([notice])里报的是**阈值本身**(256 KB / 5,000 行):实际显示量最多比阈值少 3 字节
|
||||
* (UTF-8 回退那一刀),把它精确写进一句话只会得到「只显示前 262,143 字节」这种没人想看的东西。
|
||||
*/
|
||||
data class PreviewSlice(
|
||||
val text: String,
|
||||
val truncatedBy: PreviewTruncation,
|
||||
val shownBytes: Int,
|
||||
val shownLines: Int,
|
||||
val totalBytes: Int,
|
||||
val totalLines: Int,
|
||||
) {
|
||||
|
||||
val truncated: Boolean get() = truncatedBy != PreviewTruncation.None
|
||||
|
||||
/**
|
||||
* 截断时那句**必须明写**的话;完整显示时回 `null`(一个字都不说)。
|
||||
*
|
||||
* 静默截断是这一块的红线:用户看到一段像是「文件就这么长」的正文,会得出错误结论,
|
||||
* 而真相(后面还有)**只有服务端知道**——所以这里既说「只显示前 N」,也指路两个能给
|
||||
* 完整内容的出口。
|
||||
*/
|
||||
val notice: String?
|
||||
get() {
|
||||
val shown = when (truncatedBy) {
|
||||
PreviewTruncation.None -> return null
|
||||
PreviewTruncation.Bytes -> "${DocumentPreviewLimits.MAX_BYTES / 1024} KB"
|
||||
PreviewTruncation.Lines -> "${grouped(DocumentPreviewLimits.MAX_LINES)} 行"
|
||||
}
|
||||
return "只显示前 $shown,完整内容请用「用其它应用打开」或「保存到下载目录」查看"
|
||||
}
|
||||
}
|
||||
|
||||
/** 文档正文的取段与分块(纯函数,UI 与测试共用)。 */
|
||||
object DocumentPreview {
|
||||
|
||||
/**
|
||||
* 取前 [DocumentPreviewLimits.MAX_BYTES] 字节 / [DocumentPreviewLimits.MAX_LINES] 行,
|
||||
* 先到者为准。
|
||||
*
|
||||
* 顺序是「先字节、后行数」:字节上限决定能解码出多长的文本,行数上限在这一段里再切一次。
|
||||
* 于是两者都超时,[PreviewSlice.truncatedBy] 报 [PreviewTruncation.Bytes]——那才是真正
|
||||
* 先切断它的那一刀。
|
||||
*/
|
||||
fun slice(bytes: ByteArray): PreviewSlice {
|
||||
val totalBytes = bytes.size
|
||||
val totalLines = lineCount(bytes)
|
||||
val byteLimited = totalBytes > DocumentPreviewLimits.MAX_BYTES
|
||||
val prefix = if (byteLimited) {
|
||||
utf8SafePrefixLength(bytes, DocumentPreviewLimits.MAX_BYTES)
|
||||
} else {
|
||||
totalBytes
|
||||
}
|
||||
var text = String(bytes, 0, prefix, Charsets.UTF_8)
|
||||
val lineLimited = lineCount(text) > DocumentPreviewLimits.MAX_LINES
|
||||
if (lineLimited) text = takeLines(text, DocumentPreviewLimits.MAX_LINES)
|
||||
return PreviewSlice(
|
||||
text = text,
|
||||
truncatedBy = when {
|
||||
byteLimited -> PreviewTruncation.Bytes
|
||||
lineLimited -> PreviewTruncation.Lines
|
||||
else -> PreviewTruncation.None
|
||||
},
|
||||
shownBytes = text.toByteArray(Charsets.UTF_8).size,
|
||||
shownLines = lineCount(text),
|
||||
totalBytes = totalBytes,
|
||||
totalLines = totalLines,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按行数分块([DocumentPreviewLimits.LINES_PER_CHUNK] 行一块)。
|
||||
*
|
||||
* 每块**保留自己的换行符**,因此把各块顺序拼回来等于原文——`LazyColumn` 逐块渲染时行距
|
||||
* 不会因为切点而错位。空文本回空列表(UI 不必为它画一个空块)。
|
||||
*/
|
||||
fun chunks(text: String, linesPerChunk: Int = DocumentPreviewLimits.LINES_PER_CHUNK): List<String> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
if (linesPerChunk <= 0) return listOf(text)
|
||||
val chunks = mutableListOf<String>()
|
||||
var start = 0
|
||||
var lines = 0
|
||||
for (index in text.indices) {
|
||||
if (text[index] != '\n') continue
|
||||
lines++
|
||||
if (lines == linesPerChunk) {
|
||||
chunks += text.substring(start, index + 1)
|
||||
start = index + 1
|
||||
lines = 0
|
||||
}
|
||||
}
|
||||
if (start < text.length) chunks += text.substring(start)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** 文本行数:`\n` 计数 + 末尾那行(末尾换行符不算额外一行);空文本是 0 行。 */
|
||||
fun lineCount(text: String): Int = when {
|
||||
text.isEmpty() -> 0
|
||||
text.endsWith('\n') -> text.count { it == '\n' }
|
||||
else -> text.count { it == '\n' } + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节层面的行数——**不解码整份文件**就能算出行数,因此 [slice] 报「共多少行」时
|
||||
* 不必把 20 MiB 全文变成一个 `String`。
|
||||
*
|
||||
* 直接数 `0x0A`:UTF-8 的续字节(`0x80..0xBF`)与各多字节序列里都不可能出现 `0x0A`,
|
||||
* 所以字节计数与字符计数一致。
|
||||
*/
|
||||
fun lineCount(bytes: ByteArray, length: Int = bytes.size): Int {
|
||||
if (length <= 0) return 0
|
||||
var newlines = 0
|
||||
for (index in 0 until length) {
|
||||
if ((bytes[index].toInt() and 0xFF) == NEWLINE) newlines++
|
||||
}
|
||||
return if ((bytes[length - 1].toInt() and 0xFF) == NEWLINE) newlines else newlines + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 [limit] 处取前截断时,把末尾一个**被切成两半的多字节 UTF-8 字符**退回。
|
||||
*
|
||||
* 不退的后果是正文末尾多出一个 `U+FFFD`(`String(bytes, UTF_8)` 的替换字符)——看着像
|
||||
* 文件本身就是坏的,而实际上是本端自己切坏的。退回最多 3 字节即可(UTF-8 最长 4 字节)。
|
||||
*
|
||||
* @return 可以安全解码的字节数(≤ [limit])。输入本身不合法(整段都是续字节)时原样返回
|
||||
* [limit]——不替一个坏文件找补。
|
||||
*/
|
||||
fun utf8SafePrefixLength(bytes: ByteArray, limit: Int): Int {
|
||||
if (limit >= bytes.size) return bytes.size
|
||||
if (limit <= 0) return 0
|
||||
var lead = limit
|
||||
var continuation = 0
|
||||
while (
|
||||
lead > 0 &&
|
||||
continuation < UTF8_MAX_BYTES - 1 &&
|
||||
(bytes[lead - 1].toInt() and 0xC0) == UTF8_CONTINUATION
|
||||
) {
|
||||
lead--
|
||||
continuation++
|
||||
}
|
||||
if (lead == 0) return limit
|
||||
val expected = when (bytes[lead - 1].toInt() and 0xFF) {
|
||||
in 0x00..0x7F -> 1
|
||||
in 0xC0..0xDF -> 2
|
||||
in 0xE0..0xEF -> 3
|
||||
in 0xF0..0xF7 -> 4
|
||||
else -> 1
|
||||
}
|
||||
val available = limit - lead + 1
|
||||
return if (available < expected) lead - 1 else limit
|
||||
}
|
||||
|
||||
/** 取前 [count] 行(含第 [count] 行的换行符);不足 [count] 行时原样返回。 */
|
||||
private fun takeLines(text: String, count: Int): String {
|
||||
if (count <= 0) return ""
|
||||
var seen = 0
|
||||
for (index in text.indices) {
|
||||
if (text[index] != '\n') continue
|
||||
seen++
|
||||
if (seen == count) return text.substring(0, index + 1)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private const val NEWLINE = 0x0A
|
||||
private const val UTF8_MAX_BYTES = 4
|
||||
private const val UTF8_CONTINUATION = 0x80
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览取字节失败时的文案(纯函数,可单测)。
|
||||
*
|
||||
* 与 [top.yeij.cyrene.ui.chat.documentUploadFailureMessage] 同一纪律:服务端能说清的就用它
|
||||
* 的原话(含真实原因),本地能分类的就分类说,**绝不把失败渲染成「空文件」**。
|
||||
*
|
||||
* 三条本地失败必须分开,因为它们要用户做的事不一样:读不到 = 重新选一份;太大 = 换一份;
|
||||
* 缓存写不进去 = 这是本端的问题,重试多半还是同样的结果。
|
||||
*/
|
||||
fun documentLoadFailureMessage(e: Throwable): String = when (e) {
|
||||
is LocalDocumentUnavailableException -> "读不到这份文件,请重新选择"
|
||||
is LocalDocumentTooLargeException -> "这份文件超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
|
||||
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时无法预览这份附件"
|
||||
else -> when (val failure = e.toDocumentFailure()) {
|
||||
// 服务端对「名字非法」与「文件不存在」刻意同码(不泄露差异)——本端只说能确定的那半句。
|
||||
is DocumentFailure.NotFound -> "服务端上已经没有这份文件了"
|
||||
is DocumentFailure.Other -> "附件加载失败," + failure.message
|
||||
else -> failure.message
|
||||
}
|
||||
}
|
||||
|
||||
/** 千分位分组(只用于行数这类计数,纯展示)。 */
|
||||
private fun grouped(value: Int): String = String.format(Locale.ROOT, "%,d", value)
|
||||
@@ -0,0 +1,31 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 文档抽取状态(契约 **v0.59 已发布(api-client.md §1.2「文档存储与摄取契约」)**,形状依据见 `02-前后端协同事项.md` 回执五 §一 S3-A/S3-B)。
|
||||
*
|
||||
* **开放集合,不用 enum**:后端的 `status` 是自由字符串,客户端映射只认这三个已知值,
|
||||
* **未知值原样保留**(不丢弃、不报错、不占位符)——与 `mode` / `platform` / `mood` 同一口径。
|
||||
*
|
||||
* 三个值的语义(**别互相顶替**):
|
||||
* - [EXTRACTED]:正文已抽取并进了这一轮的上下文;
|
||||
* - [LISTED]:只给了模型一份附件清单(名 / MIME / 大小 / 结论),**正文没进上下文**;
|
||||
* 它不是错误态——大文件、超预算、或本期不支持的格式都可能停在这里;
|
||||
* - [UNREADABLE]:**读不到这个文件**。这是唯一一个「昔涟看不到内容」的可观测信号,
|
||||
* 与「内容被截断」(`truncated`)是两件事:截断者正文照样进了上下文,只是尾巴被预算切掉。
|
||||
*/
|
||||
object DocumentStatus {
|
||||
|
||||
const val EXTRACTED: String = "extracted"
|
||||
|
||||
const val LISTED: String = "listed"
|
||||
|
||||
const val UNREADABLE: String = "unreadable"
|
||||
|
||||
/**
|
||||
* 这条引用是否报告「读不到」。
|
||||
*
|
||||
* 只比 [UNREADABLE],**不猜**:服务端是本信号的唯一生产者(回合结束后回写该用户消息的
|
||||
* document 列——用户消息是回合前落的、结论是回合中才知道的),客户端不做二次推断。
|
||||
*/
|
||||
fun isUnreadable(status: String): Boolean = status == UNREADABLE
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 「保留 path、替换 host」:把历史图片里**绑死在上传那一刻的 host 上**的绝对地址,换到当前 baseUrl。
|
||||
*
|
||||
* ## 它修的是什么(`devdocs/02` 的 `F6①` + 回执十三 §2)
|
||||
* `POST /api/v1/upload` 的 `url` 由服务端按**请求时**的 `r.Host`(+ `X-Forwarded-Proto` / TLS)
|
||||
* 拼成绝对地址,并且**原样落库**(读侧不重写)。于是换一个服务器地址(自部署换机器、`adb reverse`
|
||||
* 换个端口、域名从 IP 换成域名)之后,旧消息里的图片地址指向的还是**当时那个 host**,本端也就取不回
|
||||
* 那些图。
|
||||
*
|
||||
* 后端确认「读取侧三条路」里**只有主模型直收图**(`multimodal=true`)会真的去 GET 那串绝对 URL,
|
||||
* 并明确认可本端自己做这一层:「你可以先在客户端做『保留 path、替换 host』」。本对象的形态就是
|
||||
* 那句原话的逐字落地:**只换 host,不碰 path**。
|
||||
*
|
||||
* ## 保守到什么程度(不认识的一律原样返回)
|
||||
* 只认这一种形态,其余**一个字符都不改**:
|
||||
*
|
||||
* ```
|
||||
* http(s)://<任意 host[:port]>/api/v1/files/<name>
|
||||
* ```
|
||||
*
|
||||
* 具体判据(全部满足才替换,否则原样返回):
|
||||
* - 以 `http://` 或 `https://` 开头(大小写不敏感);**其它一切**都不是这一层的事——`data:` 内联图、
|
||||
* `content://` / `file://` 本地引用、相对路径、裸文件名,全都原样返回,不做任何猜测
|
||||
* (与 [ImageSource.of] 的同一条纪律:形态不认识的,交给它原来那条路);
|
||||
* - 路径**恰好**是 `/api/v1/files/` 再加**一段**名字:名字非空、不含 `/`、不含 `%`、不含 `?` / `#`,
|
||||
* 也不是 `.` / `..`。多一段、带查询串或片段、带百分号编码的,都判为「不是我们认识的那个地址」
|
||||
* ——第三方 CDN 的图与别人签过名的 URL 因此一个都不会被改;
|
||||
* - [baseUrl] 本身能解出 `scheme://authority`(空串、没有 host 的都解不出,于是原样返回)。
|
||||
*
|
||||
* 替换后用的是 **baseUrl 的 scheme + authority**(所以 `http` → `https`、端口变更都跟着走),
|
||||
* **不用** baseUrl 里的路径:服务端自己拼这条地址时只取 `r.Host`,读侧 `GET /api/v1/files/{name}`
|
||||
* 也挂在根路径上,所以部署形态本来就没有路径前缀这一说。
|
||||
*
|
||||
* ## 逆作用:指向不同数据时会 404,那是**诚实的失败**
|
||||
* 如果两个 host **指向的不是同一份存储**(换的是另一台机器、数据没迁过去),替换之后取到的就是
|
||||
* `404`。这仍然比现状好:不替换的话,那个地址**永远**取不回(而且是「连不上/超时」这种更含糊的
|
||||
* 失败),替换之后至少是一个说得清的结果,而图片那条路本来就会把失败如实报出来(见
|
||||
* [imageLoadFailureMessage])。**绝不**因此退回「不替换」——那等于把「换地址后旧图还能看」这件事
|
||||
* 彻底放弃。这条取舍是刻意的:宁可诚实地失败,也不要安静地永远失败。
|
||||
*
|
||||
* ## 不做的事
|
||||
* 不改落库数据、不改上传、不碰 `documents`,也**不**在这一层做任何 IO:它只在渲染前过一遍字符串。
|
||||
* 纯函数,可单测(`ImageHostRebaseTest`)。
|
||||
*/
|
||||
object ImageHostRebase {
|
||||
|
||||
/** 服务端上传产物的读侧路径(`api-client` §1 行 3:`GET /api/v1/files/{name}`)。 */
|
||||
private const val FILES_PREFIX = "/api/v1/files/"
|
||||
|
||||
private const val HTTP_SCHEME = "http://"
|
||||
private const val HTTPS_SCHEME = "https://"
|
||||
|
||||
/**
|
||||
* 把 [raw] 里同源的图片地址换到 [baseUrl] 的 host 上;不认识的形态原样返回(判据见类型 KDoc)。
|
||||
*
|
||||
* 幂等:已经指向当前 host 的地址走完也逐字节不变。
|
||||
*
|
||||
* @param raw 历史消息里那份 `image` 字符串(或任何要交给渲染的图片地址)。
|
||||
* @param baseUrl 当前服务器地址(`AuthManager.currentBaseUrl`);空串 = 不替换。
|
||||
*/
|
||||
fun apply(raw: String, baseUrl: String): String {
|
||||
// 1. raw 必须是 http(s) 绝对地址,且能切出 authority 与 path。
|
||||
val rest = raw.absoluteRest() ?: return raw
|
||||
val slash = rest.indexOf('/')
|
||||
if (slash <= 0) return raw
|
||||
// 2. 路径必须是「恰好一段文件名的 /api/v1/files/…」。
|
||||
if (!isSameOriginFilePath(rest.substring(slash))) return raw
|
||||
// 3. baseUrl 必须能给出 scheme://authority。
|
||||
val origin = originOf(baseUrl) ?: return raw
|
||||
return origin + rest.substring(slash)
|
||||
}
|
||||
|
||||
/**
|
||||
* `http(s)://` 之后的部分(`<authority>/<path>`);不是 http(s) 绝对地址就回 `null`。
|
||||
*
|
||||
* 大小写不敏感,但**原样保留**后面那段:本函数只判断、不重写。
|
||||
*/
|
||||
private fun String.absoluteRest(): String? = when {
|
||||
startsWith(HTTP_SCHEME, ignoreCase = true) -> substring(HTTP_SCHEME.length)
|
||||
startsWith(HTTPS_SCHEME, ignoreCase = true) -> substring(HTTPS_SCHEME.length)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* `path` 是不是「恰好 `/api/v1/files/<一段名字>`」。
|
||||
*
|
||||
* 判据取最小容忍度,理由与 [ImageNames.fromServerUrl] 的同一条:这个字符串来自**别人给的**历史
|
||||
* 数据,凡是看不懂的形态就不该被我们改写(改写它等于替用户的图片猜一个新地址)。
|
||||
*/
|
||||
private fun isSameOriginFilePath(path: String): Boolean {
|
||||
if (!path.startsWith(FILES_PREFIX)) return false
|
||||
val name = path.removePrefix(FILES_PREFIX)
|
||||
if (name.isEmpty() || name == "." || name == "..") return false
|
||||
if (name.any { it == '/' || it == '\\' || it == '%' || it == '?' || it == '#' || it == '\u0000' }) {
|
||||
return false
|
||||
}
|
||||
return name.isNotBlank()
|
||||
}
|
||||
|
||||
/** `scheme://authority`;解不出来回 `null`(空串 / 没有 host / 不是 http(s) 都是解不出来)。 */
|
||||
private fun originOf(baseUrl: String): String? {
|
||||
val rest = baseUrl.absoluteRest() ?: return null
|
||||
// authority 在第一个 `/`、`?`、`#` 之前结束(正常 baseUrl 只有尾部那个 `/`;
|
||||
// 多出来的那两样不该被当成 host 的一部分)。
|
||||
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
|
||||
val scheme = baseUrl.take(baseUrl.length - rest.length)
|
||||
return scheme + authority
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.repository.DocumentCache
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.ImageTooLargeException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 被点开的这张图**从哪来**。
|
||||
*
|
||||
* 判定只发生在一处([of]),渲染、取字节、交给别的 App 三条路都从这里取来源——散成两处就会
|
||||
* 出现「预览看的是这张、打开给的却是另一张」这种半截行为。
|
||||
*
|
||||
* 三种来源是**真的事实差异**,不是实现细节:
|
||||
* - [Server]:上传产物的绝对地址(`POST /api/v1/upload` 的 `url`)。读侧 `GET /api/v1/files/{name}`
|
||||
* 是**免鉴权**的 128-bit 能力 URL,所以这条路的字节可以直接 GET(与文档读侧刻意相反)。
|
||||
* ⚠️ 已知缺口(`02/F6①`):`url` 由服务端按上传时的 `r.Host` 拼成绝对地址并原样落库
|
||||
* ⇒ **换服务器地址后旧图取不回**。本模块不修它,但也不掩盖:取不到就如实报错。
|
||||
* - [Inline]:小图内联进历史的那份 `data:` URI([ChatImageDelivery] 的 Inline 档)。字节就在
|
||||
* 这个字符串里,**不联网**。
|
||||
* - [Local]:本地引用——相册选择器的 `content://`、分享落地的 `file://`。字节要现读。
|
||||
*/
|
||||
sealed interface ImageSource {
|
||||
|
||||
/**
|
||||
* 交给渲染与取字节用的**原始**字符串:气泡里 `message.image` 的那一份,或待发图片的本地 URI。
|
||||
*
|
||||
* 刻意不在这里归一化成「某种标准地址」:渲染交给 Coil(它自己认 `data:` / `content://` /
|
||||
* `http(s)`),取字节由 [ImageCacheStore] 按来源分支。多一层转换只会多一处漂移的余地。
|
||||
*/
|
||||
val raw: String
|
||||
|
||||
/** 上传产物的绝对地址(`http(s)://<host>/api/v1/files/<name>`);读侧免鉴权。 */
|
||||
data class Server(override val raw: String) : ImageSource
|
||||
|
||||
/** 内联进历史的 `data:` URI(`data:image/png;base64,…`)。 */
|
||||
data class Inline(override val raw: String) : ImageSource
|
||||
|
||||
/** 本地引用:`content://`(相册)/ `file://`(分享落地)都走它;读不到会如实报错。 */
|
||||
data class Local(override val raw: String) : ImageSource
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* 分类(纯字符串判断,不做任何 IO)。
|
||||
*
|
||||
* 空串回 `null`——那种图本来也不会被渲染。**其余一律落到 [Local]**:一个既不是
|
||||
* `data:` 也不是 `http(s)://` 的字符串只可能是本地引用(`content://` / `file://` /
|
||||
* 裸路径),按本地读、读不到就报「读不到这张图片」。这样做的理由是**不许静默什么都不
|
||||
* 发生**:形态陌生时点一下没反应,比给出一句说得清的失败糟得多。
|
||||
*/
|
||||
fun of(raw: String): ImageSource? = when {
|
||||
raw.isBlank() -> null
|
||||
raw.startsWith("data:", ignoreCase = true) -> Inline(raw)
|
||||
raw.startsWith("http://", ignoreCase = true) -> Server(raw)
|
||||
raw.startsWith("https://", ignoreCase = true) -> Server(raw)
|
||||
else -> Local(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片的三种形态互推:**字节 ↔ mime ↔ 扩展名**。
|
||||
*
|
||||
* 放行的集合与服务端 `POST /api/v1/upload` 的白名单**同源**(png / jpeg / gif / webp / bmp):
|
||||
* 那正是服务端会接受、并且会给它起 `<32hex>.<ext>` 名字的一批。刻意**不**把 HEIC / AVIF 列进来
|
||||
* ——它们不在服务端白名单里,本端也拿不到可比对的裁决;但本地相册里的 HEIC 照样能看(渲染交给
|
||||
* Coil,解码是系统的事),只是本端说不出它的精确类型,那时「用其它应用打开」用的是**平台声明**
|
||||
* 的类型(见 [ImageOpenTypes.openType])。
|
||||
*/
|
||||
object ImageFormats {
|
||||
|
||||
const val MIME_PNG: String = "image/png"
|
||||
const val MIME_JPEG: String = "image/jpeg"
|
||||
const val MIME_GIF: String = "image/gif"
|
||||
const val MIME_WEBP: String = "image/webp"
|
||||
const val MIME_BMP: String = "image/bmp"
|
||||
|
||||
/**
|
||||
* 字节嗅探 → mime;认不出回 `null`。
|
||||
*
|
||||
* 只看文件头,不解码整张图:这一层要能在取字节之后立刻给答案,而不必把位图读进内存。
|
||||
* 判据与服务端 upload 的白名单同一套(服务端用 `http.DetectContentType`,本端按同样的签名
|
||||
* 手判)——认不出就说认不出,**不猜**(图片侧猜错的代价是让一个错的类型进 Intent)。
|
||||
*/
|
||||
fun sniff(bytes: ByteArray): String? = when {
|
||||
bytes.startsWith(PNG_MAGIC) -> MIME_PNG
|
||||
bytes.startsWith(JPEG_MAGIC) -> MIME_JPEG
|
||||
bytes.startsWith(GIF_MAGIC) -> MIME_GIF
|
||||
bytes.isWebp() -> MIME_WEBP
|
||||
bytes.isBmp() -> MIME_BMP
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** 名字/地址里的扩展名 → mime;不在图片白名单里回 `null`(不猜)。 */
|
||||
fun mimeForExtension(name: String?): String? =
|
||||
when (name?.substringAfterLast('.', "")?.lowercase(Locale.ROOT).orEmpty()) {
|
||||
"png" -> MIME_PNG
|
||||
"jpg", "jpeg" -> MIME_JPEG
|
||||
"gif" -> MIME_GIF
|
||||
"webp" -> MIME_WEBP
|
||||
"bmp" -> MIME_BMP
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** mime → 带点的扩展名(内容寻址的缓存名要用);认不出回空串。 */
|
||||
fun extensionFor(mime: String?): String = when (mime) {
|
||||
MIME_PNG -> ".png"
|
||||
MIME_JPEG -> ".jpg"
|
||||
MIME_GIF -> ".gif"
|
||||
MIME_WEBP -> ".webp"
|
||||
MIME_BMP -> ".bmp"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun ByteArray.startsWith(magic: ByteArray): Boolean =
|
||||
size >= magic.size && magic.indices.all { this[it] == magic[it] }
|
||||
|
||||
/** WEBP 是 RIFF 容器:前 4 字节 `RIFF`、第 8…11 字节 `WEBP`,只看开头是不够的。 */
|
||||
private fun ByteArray.isWebp(): Boolean =
|
||||
size >= WEBP_MARKER_OFFSET + WEBP_MARKER.size &&
|
||||
startsWith(RIFF_MAGIC) &&
|
||||
WEBP_MARKER.indices.all { this[WEBP_MARKER_OFFSET + it] == WEBP_MARKER[it] }
|
||||
|
||||
/** BMP 是 `BM` 开头的固定 14 字节文件头;只看那两字节太容易撞上以「BM」开头的文本。 */
|
||||
private fun ByteArray.isBmp(): Boolean = size >= BMP_HEADER_BYTES && startsWith(BMP_MAGIC)
|
||||
|
||||
private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
||||
private val JPEG_MAGIC = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte())
|
||||
private val GIF_MAGIC = "GIF8".toByteArray(Charsets.US_ASCII)
|
||||
private val RIFF_MAGIC = "RIFF".toByteArray(Charsets.US_ASCII)
|
||||
private val WEBP_MARKER = "WEBP".toByteArray(Charsets.US_ASCII)
|
||||
private val BMP_MAGIC = "BM".toByteArray(Charsets.US_ASCII)
|
||||
private const val WEBP_MARKER_OFFSET = 8
|
||||
private const val BMP_HEADER_BYTES = 14
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端图片地址 → 缓存名。
|
||||
*
|
||||
* ## 为什么可以拿 URL 的最后一段当文件名
|
||||
* `POST /api/v1/upload` 的产物名是服务端生成的 `<32hex>.<ext>`,落进 `<dataDir>/uploads/`,
|
||||
* 读侧只认「32 位小写 hex + 白名单扩展名」。同一个名字的字节**永不改变**(上传产物没有覆盖
|
||||
* 写入口),所以**名字就是缓存键、命中即永不失效**——与文档那条内容寻址的结论一致。
|
||||
*
|
||||
* ## 为什么容忍度取最小
|
||||
* 这个名字会变成 `cacheDir/documents/` 下的一个文件名,而那个目录正是 FileProvider 唯一暴露出去
|
||||
* 的根(见 [DocumentCache.isSafeName])。所以只接受「最后一段、单段、非空、不是 `.` / `..`、
|
||||
* 不含分隔符与 NUL、不含 `%`、扩展名在图片白名单里」的名字;任何一条不满足就回 `null`,
|
||||
* 由调用方**回落到按字节内容寻址**——宁可多落一份文件,也绝不用一个不可信的名字拼路径。
|
||||
*
|
||||
* 对 `%` 格外苛刻是有意的:本端**从不**解释 URL 编码,所以一个带 `%` 的段说明它压根不是我们
|
||||
* 认识的那个服务端名字(服务端的读侧也明确拒绝 `%2f` / `%2e%2e` 这些形态)。
|
||||
*/
|
||||
object ImageNames {
|
||||
|
||||
/** 从图片地址里推出缓存名;推不出来 / 不可信时回 `null`。 */
|
||||
fun fromServerUrl(url: String): String? {
|
||||
// 先切掉 fragment 与查询串:缓存名只认路径那一段(`?v=2` 这种不该进文件名)。
|
||||
val path = url.substringBefore('#').substringBefore('?')
|
||||
val name = path.substringAfterLast('/')
|
||||
if (name.isBlank() || name == "." || name == "..") return null
|
||||
if (name.any { it == '\\' || it == '\u0000' || it == '%' }) return null
|
||||
if (!DocumentCache.isSafeName(name)) return null
|
||||
return name.takeIf { ImageFormats.mimeForExtension(it) != null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内联图片(`data:image/png;base64,…`)的解析。
|
||||
*
|
||||
* 用 JDK 的 `java.util.Base64` 而**不是** `android.util.Base64`:这一层是纯逻辑,要能在 JVM
|
||||
* 单测里直接跑——`android.util` 在单测里是 stub,一碰就抛。
|
||||
*/
|
||||
object ImageDataUri {
|
||||
|
||||
/** 头部声明的 mime(`data:image/png;base64,…` → `image/png`);没有就回 `null`。 */
|
||||
fun declaredMime(dataUri: String): String? = dataUri
|
||||
.substringAfter("data:", "")
|
||||
.substringBefore(',')
|
||||
.substringBefore(';')
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* 解出内联的字节;不是 base64 的 `data:` URI / 解不出来 / 解出来是空的都回 `null`。
|
||||
*
|
||||
* 用 MIME 解码器(忽略行内换行等非字母表字符)而不是严格解码器:`data:` URI 里的 base64
|
||||
* 允许折行,严格解码器会把一整张能看的图判成坏数据。代价是「一整串垃圾」也可能解出一段垃圾
|
||||
* 字节——那一段走到渲染那一步会解不开,由调用方如实报「解不开」,**不会**画成一张空白图。
|
||||
*/
|
||||
fun bytes(dataUri: String): ByteArray? {
|
||||
if (!dataUri.startsWith("data:", ignoreCase = true)) return null
|
||||
val comma = dataUri.indexOf(',')
|
||||
if (comma < 0) return null
|
||||
val head = dataUri.substring("data:".length, comma)
|
||||
if (!head.contains(";base64", ignoreCase = true)) return null
|
||||
return runCatching {
|
||||
Base64.getMimeDecoder().decode(dataUri.substring(comma + 1))
|
||||
}.getOrNull()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「用其它应用打开」时 Intent 上要声明的 MIME,以及「保存到下载目录」时给 SAF 的类型。
|
||||
*
|
||||
* 与文档那条路同一纪律,但**两条路的类型选择刻意不同**(W11 实机抓到的真 bug):
|
||||
* - **打开**要的正是「能读懂这段内容的类型」⇒ 尽可能精确(嗅探 > 平台声明 > 扩展名 > 图片通配);
|
||||
* - **保存**恒为「通配」类型([ImageOpenTypes.SAVE_MIME])⇒ `CreateDocument` 传具体 mime 时,
|
||||
* SAF 的 Downloads provider 会按它给文件名**补扩展名**(2026-10-05 实测:存的 `…e.md` 变成
|
||||
* `…e.md.txt`)。图片这边同样危险:服务端名字的扩展名与**字节嗅探**所得的基类型不保证一致,
|
||||
* 补一次就成了 `x.jpg.png`。
|
||||
*/
|
||||
object ImageOpenTypes {
|
||||
|
||||
/** 说不清是什么图时的兜底:让图片类应用都能接(不是 `application/octet-stream`)。 */
|
||||
const val ANY_IMAGE: String = "image/*"
|
||||
|
||||
/** 保存到下载目录时给 SAF 的类型:**恒为通配**(为什么不能用具体 mime 见本对象的 KDoc)。 */
|
||||
const val SAVE_MIME: String = "*/*"
|
||||
|
||||
private const val IMAGE_PREFIX = "image/"
|
||||
|
||||
/**
|
||||
* 交给别的 App 时声明的类型,四级回落。
|
||||
*
|
||||
* 顺序是**证据强度**的顺序,不是「谁先拿到」的顺序:
|
||||
* 1. [sniffed] —— 字节嗅探。服务端对图片也是这个口径(`DetectContentType` 是唯一裁决),
|
||||
* 本端拿到的是同一份字节,所以它比任何名字和声明都硬;
|
||||
* 2. [declared] —— 平台声明(本地来源的 `ContentResolver.getType` / 内联 `data:` URI 的头)。
|
||||
* **只认 `image/` 开头的那种**:有些 provider 对图片报 `application/octet-stream`,
|
||||
* 拿它去开图等于告诉对方「这是一坨字节」,不如退到扩展名;
|
||||
* 3. [name] 的扩展名 —— 服务端名字里的扩展名来自它自己那一次嗅探,是可信的第二手证据;
|
||||
* 4. [ANY_IMAGE]。
|
||||
*/
|
||||
fun openType(sniffed: String?, declared: String?, name: String?): String {
|
||||
sniffed?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
declared?.trim()?.takeIf { it.startsWith(IMAGE_PREFIX) }?.let { return it }
|
||||
ImageFormats.mimeForExtension(name)?.let { return it }
|
||||
return ANY_IMAGE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取这张图的字节失败时的文案(纯函数,可单测)。
|
||||
*
|
||||
* 与 [documentLoadFailureMessage] 同一纪律:服务端能说清的就用它的原话,本地能分类的就分类说,
|
||||
* **绝不把失败渲染成一张空白图**。三条本地失败分开写,因为它们要用户做的事不一样:读不到 =
|
||||
* 再点一次或重新发一张;太大 = 这个本端处理不了;缓存写不进去 = 本端的问题,重试多半还是这样。
|
||||
*
|
||||
* 三个异常类型与文档共用(它们描述的是**缓存层**的三种处境,不是文档专属);两边的文案各自成句。
|
||||
*/
|
||||
fun imageLoadFailureMessage(e: Throwable): String = when (e) {
|
||||
is LocalDocumentUnavailableException -> "读不到这张图片"
|
||||
is LocalDocumentTooLargeException -> "这张图片超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
|
||||
is ImageTooLargeException -> "这张图片超过 ${DocumentAttachments.maxFileSizeLabel},无法预览"
|
||||
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时无法预览这张图片"
|
||||
is CyreneException -> when (e.toDocumentFailure()) {
|
||||
// 服务端对「名字非法」与「文件不存在」刻意同码(不泄露差异)——本端只说能确定的那半句。
|
||||
is DocumentFailure.NotFound -> "服务端上已经没有这张图片了"
|
||||
else -> "图片加载失败," + e.error.userMessage
|
||||
}
|
||||
|
||||
else -> "图片加载失败,请稍后重试"
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 一个矩形尺寸(像素)。
|
||||
*
|
||||
* 刻意**不用** Compose 的 `Size` / `IntSize`:这一层是纯手势数学,要能在 JVM 单测里直接跑,
|
||||
* 也不想让 domain 依赖 UI 框架。UI 那边把 `IntSize` 换算成它,只有两行。
|
||||
*/
|
||||
data class ImageSize(val width: Float, val height: Float)
|
||||
|
||||
/** 全屏查看的缩放 / 平移:喂给 `graphicsLayer` 的三个数。 */
|
||||
data class ZoomPan(
|
||||
val scale: Float = ImageZoom.MIN_SCALE,
|
||||
val offsetX: Float = 0f,
|
||||
val offsetY: Float = 0f,
|
||||
)
|
||||
|
||||
/**
|
||||
* 全屏查看的缩放 / 平移数学(纯函数,UI 只负责把触摸事件喂进来、把结果画出去)。
|
||||
*
|
||||
* ## 坐标约定
|
||||
* `offsetX` / `offsetY` 是**相对容器中心**的位移;[ImageSize] 给出两个尺寸:
|
||||
* - `container`:手势区的大小;
|
||||
* - `image`:图片的原始像素尺寸(只要宽高比正确即可,缩略图尺寸也行)。
|
||||
*
|
||||
* 于是「scale = 1」就是 **fit-center**:[fittedSize] 算出的内容尺寸(等比、贴住容器的一边)。
|
||||
* 任何偏移都在 [clampOffset] 里被钳到「内容超出容器的那一半」以内——内容比容器小的那一轴恒为 0,
|
||||
* 因此 fit 态下**根本拖不动**,图片永远不可能被拖到看不见的地方。
|
||||
*
|
||||
* ## 为什么下界是 1x 而不是任务书里那句「不低于 0.5x」
|
||||
* 1x 就是「整张图刚好放得下」。再往下缩只会让图比屏幕还小、四周留白,没有任何能看的东西,
|
||||
* 所以这里取一个**更紧**的下界(0.5x 满足「不低于 0.5x」,但它是个不需要的状态)。
|
||||
* 上界 3x:比这更大就只能看几个像素,而双指继续外扩还有系统的手势冲突风险。
|
||||
*/
|
||||
object ImageZoom {
|
||||
|
||||
/** 下界 = fit-center(见 KDoc)。 */
|
||||
const val MIN_SCALE: Float = 1f
|
||||
|
||||
/** 上界。 */
|
||||
const val MAX_SCALE: Float = 3f
|
||||
|
||||
/** 双击放到的倍数(双击是 1x ↔ 2x 的开关,不是「放大一点」)。 */
|
||||
const val DOUBLE_TAP_SCALE: Float = 2f
|
||||
|
||||
/** 判定「还在 fit 态」的容差:`clampScale` 的产物就是字面量 1f,但浮点乘法会留下尾巴。 */
|
||||
private const val EPSILON = 0.01f
|
||||
|
||||
/** 缩放钳制:非有限值(NaN / Infinity,来自 `zoom` 因子的极端输入)一律回 [MIN_SCALE]。 */
|
||||
fun clampScale(scale: Float): Float =
|
||||
if (scale.isFinite()) scale.coerceIn(MIN_SCALE, MAX_SCALE) else MIN_SCALE
|
||||
|
||||
/** 是否处在放大态(双击据此决定「放大到 2x」还是「收回 1x」)。 */
|
||||
fun isZoomed(scale: Float): Boolean = scale > MIN_SCALE + EPSILON
|
||||
|
||||
/** 双击的目标倍数:fit 态 → 2x;已放大 → 回 fit。 */
|
||||
fun doubleTapScale(current: Float): Float =
|
||||
if (isZoomed(current)) MIN_SCALE else DOUBLE_TAP_SCALE
|
||||
|
||||
/**
|
||||
* fit-center 之后、scale = 1 时的内容尺寸(等比缩到贴住容器的一边)。
|
||||
*
|
||||
* 任一侧非正(还没测量出来 / 图片尺寸未知)时回 `(0, 0)`:调用方据此跳过钳制,
|
||||
* 而不是拿 `NaN` 去算位移。
|
||||
*/
|
||||
fun fittedSize(container: ImageSize, image: ImageSize): ImageSize {
|
||||
if (container.width <= 0f || container.height <= 0f) return ImageSize(0f, 0f)
|
||||
if (image.width <= 0f || image.height <= 0f) return ImageSize(0f, 0f)
|
||||
val fit = minOf(container.width / image.width, container.height / image.height)
|
||||
return ImageSize(image.width * fit, image.height * fit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单轴的位移上限:内容放大后**超出容器的那一半**(内容比容器小时为 0)。
|
||||
*
|
||||
* 取一半是因为偏移以容器中心为原点:往右最多能移「超出量 / 2」,再移就会露出空白。
|
||||
*/
|
||||
fun maxOffset(containerSide: Float, contentSide: Float, scale: Float): Float {
|
||||
if (!containerSide.isFinite() || !contentSide.isFinite() || !scale.isFinite()) return 0f
|
||||
return ((contentSide * scale - containerSide) / 2f).coerceAtLeast(0f)
|
||||
}
|
||||
|
||||
/** 单轴钳制(越界即贴边;fit 态下恒为 0,所以拖不动)。 */
|
||||
fun clampOffset(offset: Float, containerSide: Float, contentSide: Float, scale: Float): Float {
|
||||
if (!offset.isFinite()) return 0f
|
||||
val limit = maxOffset(containerSide, contentSide, scale)
|
||||
return offset.coerceIn(-limit, limit)
|
||||
}
|
||||
|
||||
/** 拖动平移:先加上手指位移,再按两轴各自钳制。 */
|
||||
fun pan(
|
||||
current: ZoomPan,
|
||||
deltaX: Float,
|
||||
deltaY: Float,
|
||||
container: ImageSize,
|
||||
image: ImageSize,
|
||||
): ZoomPan = clamped(
|
||||
scale = current.scale,
|
||||
offsetX = current.offsetX + deltaX,
|
||||
offsetY = current.offsetY + deltaY,
|
||||
container = container,
|
||||
image = image,
|
||||
)
|
||||
|
||||
/**
|
||||
* 以 [focusX] / [focusY](**相对容器中心**的坐标)为不动点缩放到 [targetScale]。
|
||||
*
|
||||
* 推导:屏幕上被手指按住的那个点在内容坐标里是 `c = (focus - offset) / scale`;缩放后要让它
|
||||
* 落在同一处,于是 `offset' = focus - c * scale'`,即 `offset' = focus - (focus - offset) * (scale'/scale)`。
|
||||
* 缩回 fit 时这个式子算出来的位移会被 [clampOffset] 归零(内容不再超出容器),正是要的结果。
|
||||
*/
|
||||
fun zoomAround(
|
||||
current: ZoomPan,
|
||||
targetScale: Float,
|
||||
focusX: Float,
|
||||
focusY: Float,
|
||||
container: ImageSize,
|
||||
image: ImageSize,
|
||||
): ZoomPan {
|
||||
val scale = clampScale(targetScale)
|
||||
val base = if (current.scale.isFinite() && current.scale > 0f) current.scale else MIN_SCALE
|
||||
val ratio = scale / base
|
||||
return clamped(
|
||||
scale = scale,
|
||||
offsetX = focusX - (focusX - current.offsetX) * ratio,
|
||||
offsetY = focusY - (focusY - current.offsetY) * ratio,
|
||||
container = container,
|
||||
image = image,
|
||||
)
|
||||
}
|
||||
|
||||
/** 双击:以双击点为不动点在 fit 与 [DOUBLE_TAP_SCALE] 之间切换(回 fit 时位移自然归零)。 */
|
||||
fun doubleTap(
|
||||
current: ZoomPan,
|
||||
focusX: Float,
|
||||
focusY: Float,
|
||||
container: ImageSize,
|
||||
image: ImageSize,
|
||||
): ZoomPan = zoomAround(
|
||||
current = current,
|
||||
targetScale = doubleTapScale(current.scale),
|
||||
focusX = focusX,
|
||||
focusY = focusY,
|
||||
container = container,
|
||||
image = image,
|
||||
)
|
||||
|
||||
private fun clamped(
|
||||
scale: Float,
|
||||
offsetX: Float,
|
||||
offsetY: Float,
|
||||
container: ImageSize,
|
||||
image: ImageSize,
|
||||
): ZoomPan {
|
||||
val fitted = fittedSize(container, image)
|
||||
return ZoomPan(
|
||||
scale = scale,
|
||||
offsetX = clampOffset(offsetX, container.width, fitted.width, scale),
|
||||
offsetY = clampOffset(offsetY, container.height, fitted.height, scale),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.core.prefs.InterruptMode
|
||||
|
||||
/**
|
||||
* 「她忙时发消息」这一档偏好在上行的**两个声明**(纯函数,VM 与测试共用):
|
||||
* 协同项 A2 的 `interrupt.scope`([interruptScopeForSend])与忙时并行规范 §2.2 的
|
||||
* `chat.parallel`([parallelForSend] / [parallelWireValue])。
|
||||
*
|
||||
* 文件名沿用了先有的那个(`InterruptScope`):并行那一半接上来时,两者是**同一个判据的两个方向**
|
||||
* (打断 vs 不打断),放在同一处比拆成两个文件更容易看出它们互斥。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 发这条消息**之前**要不要先发一条 `interrupt`:返回要发的作用域,`null` = 一个字节都不发
|
||||
* (纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 四条判据(协同 A2 的客户端半边,契约 v0.56 §3.1)
|
||||
*
|
||||
* 1. [InterruptMode.AppendQueue](**默认档**)⇒ 恒为 `null`,线上请求与接入本函数之前**逐字节相同**。
|
||||
* 这是最重要的一条:默认档不许有任何行为变化。
|
||||
* 2. [InterruptMode.Interrupt](`wireScope = "current"`)**且会话正忙** ⇒ `"current"`:
|
||||
* 只取消在跑回合、**保留待处理队列**(队列里的消息按编排器正常续跑,一条都不丢)。
|
||||
* 3. [InterruptMode.Interrupt] 但会话**空闲** ⇒ `null`:命中空闲会话在服务端是 no-op,白发一条帧
|
||||
* 只是多一次往返,还会在 logcat 里制造「这里打断过」的假象。
|
||||
* 4. [InterruptMode.Parallel] ⇒ **恒为 `null`**(它的 `wireScope` 就是 `null`):并行与打断互斥,
|
||||
* 并行档先打断就恰好砍掉它要并行的那一路。这一条不是「这一档没得发」,而是**结构性保证**——
|
||||
* 谓词只读 `wireScope`,并行档不可能发出打断帧。
|
||||
*
|
||||
* 判据里**没有**「队列有多深」这一项,见下。
|
||||
*
|
||||
* ## 「正忙」由谁判
|
||||
* 只认**服务端给的** `session_busy{mode}`(调用方传 `ChatUiState.busyMode != null`)。
|
||||
* **不用 `queueDepth`**:队列深度非零不等于有回合在跑(`pending_ack` / `pending_drain` 这类帧也会动它),
|
||||
* 拿它当判据就会在空闲会话上乱发打断帧。
|
||||
*
|
||||
* @param busy 这条消息要发出去的那一刻,该会话是否有回合在跑。
|
||||
*/
|
||||
fun interruptScopeForSend(mode: InterruptMode, busy: Boolean): String? =
|
||||
if (busy) mode.wireScope else null
|
||||
|
||||
/**
|
||||
* 发这条消息时要不要声明 `parallel:true`(忙时并行规范 §2.2,[interruptScopeForSend] 的兄弟)。
|
||||
*
|
||||
* ## 三条判据
|
||||
*
|
||||
* 1. [InterruptMode.Parallel] **且会话正忙** ⇒ `true`:不打断在跑的那一路,让本条**另起一路**
|
||||
* 同时处理(服务端额度满时不报错,这一条落回排队)。
|
||||
* 2. 另外两档 ⇒ **恒为 `false`**:`parallel` 是纯增量键,省略它线上请求与接入本判据之前**逐字节相同**
|
||||
* ([InterruptMode.AppendQueue] 与 [InterruptMode.Interrupt] 的 `wireParallel` 都是 `false`)。
|
||||
* 3. 并行的同时**绝不打断**:两件事是一对互斥语义(打断 = 先砍掉她正在说的这一句,并行 = 不打断、
|
||||
* 另起一路),所以 [InterruptMode.Parallel] 的 `wireScope` 是 `null` ⇒ 它在
|
||||
* [interruptScopeForSend] 里恒为「不发」。反过来,另外两档的 `wireParallel` 是 `false` ⇒
|
||||
* 它们永远不带 `parallel` 键。「绝不既打断又并行」不是约定,是这两处属性取值本身
|
||||
* (`ParallelForSendTest` 把它钉成穷举断言)。
|
||||
*
|
||||
* ## 为什么空闲时不声明
|
||||
* 规范 §2.2 只说 `parallel:true` 是「**请求**把本条作为独立一路处理」,**没写**空闲时要不要照发。
|
||||
* 空闲时这一条**本来就是它自己的回合**,声明并行与不声明在服务端是同一件事;而多带一个键就多一处
|
||||
* 线上形状差异。于是本端取**与 [interruptScopeForSend] 同一个判据**(忙才声明)——最小惊讶:
|
||||
* 空闲时三档**逐字节**完全相同,切档不会改变一条普通发送的任何字节。
|
||||
*
|
||||
* @param busy 这条消息要发出去的那一刻,该会话是否有回合在跑(与 [interruptScopeForSend] 同一个来源)。
|
||||
*/
|
||||
fun parallelForSend(mode: InterruptMode, busy: Boolean): Boolean = busy && mode.wireParallel
|
||||
|
||||
/**
|
||||
* 把 [parallelForSend] 的结论翻成帧 / DTO 那个可选字段的取值:`false` ⇒ `null`(**整键省略**)。
|
||||
*
|
||||
* 为什么必须翻一道:`WsOutbound.Chat.parallel` 与 `ChatRequest.parallel` 的默认值都是 `null`,
|
||||
* 而「省略」与「显式 `false`」在服务端**不是一回事**(前者 = 未声明,后者 = 明确不要并行);
|
||||
* kotlinx 的 `encodeDefaults = false` 只省略「等于默认值」的字段 ⇒ 把 `false` 直接传下去,会在
|
||||
* **每一条**请求上多出一个 `"parallel":false` 键,默认档就不再逐字节不变了。两个发送层共用这一个
|
||||
* 转换点,免得哪天有人在其中一处「顺手」把布尔原样传下去。
|
||||
*/
|
||||
fun parallelWireValue(declare: Boolean): Boolean? = if (declare) true else null
|
||||
@@ -0,0 +1,106 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
|
||||
/*
|
||||
* MessageEditText —— 「一条消息在编辑框里长什么样」的纯逻辑(无 Android / Compose 依赖)。
|
||||
*
|
||||
* 口径:编辑框只显示**纯文本**,但绝不静默丢东西。两件事必须同时成立:
|
||||
* 1. 原文本来就是纯文本 ⇒ 显示与保存都**逐字节恒等**(`hasMarkup == false`);
|
||||
* 2. 原文真的带标记 ⇒ 显示剥掉标记的可见文本,并由弹层**明说**保存会把那些标记换成用户写的这段字
|
||||
* (`hasMarkup == true`,见 `ui/chat/MessageEditDialog.kt`)。
|
||||
* 所以「剥标记」这条路只对**真有标记**的内容生效,对今天真实的主会话数据(user 消息全是纯文本)
|
||||
* 是一条恒等变换——它是防御,不是修一个正在发生的 bug。这也正是不能顺手把纯文本 trim /
|
||||
* 折叠空行 / 统一换行符的原因:那会把恒等变成「悄悄地改」。
|
||||
*
|
||||
* 剥标记**只用** `domain/parse` 这一个解析器([parse]),不另写正则:两套口径必然分叉,
|
||||
* 屏幕上读到的与她实际改的就会是两段不同的文本。拼纯文本的做法与 `ui/chat/SessionSearchModel.kt`
|
||||
* 的 `plainSearchText` 同源(同一份 `parse` 结果、同一句「按片段顺序取文本」),
|
||||
* 只是彼此的用途不同:搜索片段折成一行、这里保持段落,所以两者的拼接方式各自写在自己的文件里。
|
||||
*
|
||||
* 为什么保存时**不**把标记装回去:任何回填都要先猜「旁白/动作该插回哪一句之间」,
|
||||
* 而这不是本端有权替用户决定的事——猜错就是静默移动她说过的话的位置,且用户看不出发生了什么。
|
||||
* 提交的就是编辑框里那串纯文本本身,标记的归宿由弹层那一行小字**提前说清**。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 一条消息在编辑框里的样子(纯数据)。
|
||||
*
|
||||
* 两个字段是一体的:`text` 是用户看得到、也真的要动手改的那串字,`hasMarkup` 说的是
|
||||
* 「保存会不会因此丢掉别的东西」——弹层靠它决定要不要讲那一句话。
|
||||
*/
|
||||
data class EditableMessage(
|
||||
/** 编辑框显示、也就是用户实际编辑的**纯文本**。 */
|
||||
val text: String,
|
||||
/**
|
||||
* 原文里**确实带标记**(`<p>` / `<action>` / `<narration>`)⇒ 保存会把那些标记换成 `text`。
|
||||
* false = 原文就是纯文本,显示与保存都是**恒等**的。
|
||||
*/
|
||||
val hasMarkup: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 已知标签的容差,与 [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*>",
|
||||
)
|
||||
|
||||
/** 气泡之间的分隔:一个空行——屏幕上读到的两段之间就是这么空着的。 */
|
||||
private const val BUBBLE_SEPARATOR = "\n\n"
|
||||
|
||||
/**
|
||||
* 算出这条消息在编辑框里的样子(纯函数,无副作用)。
|
||||
*
|
||||
* 三分支,覆盖「标记只在真的有标记时才动手」这条红线:
|
||||
* - **原文不含任何已知标签** ⇒ 原样返回,且**一个字符都不动**(不 trim、不折叠连续空行、不换换行符);
|
||||
* - **含标签且解析出了气泡** ⇒ `text` = 可见纯文本(见下);
|
||||
* - **含标签但一个气泡都解析不出**(例如整条就是一个没闭合的畸形标签)⇒ `text` 回落成原文:
|
||||
* 宁可原样留着,也不赌一把去删掉屏幕上说不清是什么的东西。
|
||||
*
|
||||
* `hasMarkup` 只在「解析**真的**换掉了什么」时为 true,也就是 `text != content`:
|
||||
* 它承诺的是「保存后这条消息和你现在看到的不一样」,而这个承诺必须句句属实——
|
||||
* 畸形标签、`<p> </p>` 这类解析器按普通文本处理的输入走的是恒等路径,
|
||||
* 那就该如实说「什么都没被替换」,而不是先吓用户一下。
|
||||
*/
|
||||
fun editableMessageOf(content: String): EditableMessage {
|
||||
if (!KNOWN_TAG_RE.containsMatchIn(content)) return EditableMessage(content, hasMarkup = false)
|
||||
val text = visibleTextOf(content)
|
||||
return EditableMessage(text, hasMarkup = text != content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记 → 可见纯文本:每个气泡按**原始片段顺序**取文本(正文与旁白/动作一视同仁,都不丢),
|
||||
* 气泡之间一个空行,最后整体 trim。
|
||||
*
|
||||
* 按 `parts` 顺序拼而不是按 head/body/tail 拼:`parts` 就是原文顺序,[parse] 的 head 只是
|
||||
* 「该把这块画在气泡上方」的渲染提示,用它重排会改掉用户读到的次序。
|
||||
*
|
||||
* 每个气泡**自己也 trim**(然后才是气泡之间的空行):气泡在屏幕上本就是独立的一块,
|
||||
* 块内的首尾空白渲染上不显示;不 trim 的话 `</p>\n\n<p>` 这种 Web 形态的块间空行会与
|
||||
* 分隔符叠加成四个换行,读起来就不是屏幕上那两段了。这里去掉的只可能是空白,
|
||||
* 一个可见字符都不会掉。
|
||||
*
|
||||
* 一个气泡都解析不出时**原样返回 `content`**:那是「宁可不剥」的落点,不是错误。
|
||||
*/
|
||||
private fun visibleTextOf(content: String): String {
|
||||
val bubbles = parse(content)
|
||||
if (bubbles.isEmpty()) return content
|
||||
return bubbles
|
||||
.joinToString(BUBBLE_SEPARATOR) { bubble -> bubble.parts.joinToString("") { it.text }.trim() }
|
||||
.trim()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 一条待发消息的**内容指纹**:正文 + 图片的本地引用 + 附件的本地引用(纯值,可比较、可单测)。
|
||||
*
|
||||
* ## 它回答的问题
|
||||
* 「用户这次按发送,是不是在**重试**刚才那条没发出去的消息?」——只有答「是」,本端才敢把
|
||||
* 那一次用过的**幂等键**(`clientMsgId`)接着用;答「不是」就必须铸新键,否则服务端会把用户
|
||||
* **改过的那条**当成重复到达(`chat_duplicate` / `{duplicate:true}`)静默丢掉——他的编辑一个字
|
||||
* 都发不出去。
|
||||
*
|
||||
* ## 为什么是「值相等」而不是哈希 / 摘要
|
||||
* 指纹相等 = 本端认定「同一条消息」 = 复用幂等键。摘要一旦碰撞,两条**不同**的消息就会共用
|
||||
* 一个键,后一条被服务端判成重复而丢掉;而摘要在这里买不到任何东西(比较只发生在进程内,
|
||||
* 没有要跨网络传的指纹)。所以直接拿可比较的值本身当指纹,碰撞面为零。
|
||||
*
|
||||
* ## 为什么正文不做任何归一
|
||||
* 一个空白字符的差别也算「改过字」:trim / 折叠空白都可能把用户真实打算发的两条消息并成一条,
|
||||
* 而那正是本类型要防的事。发送路径传进来的已经是发送那一刻的正文(`ChatViewModel.send` 先
|
||||
* `trim`),所以「重试时输入栏里原样躺着的还是同一段字」天然相等。
|
||||
*
|
||||
* ## 附件为什么只取 uri
|
||||
* `DraftDocument` 的 `displayName` / `sizeBytes` 是**元数据**:同一份本地文件在两次
|
||||
* `ContentResolver` 查询之间完全可能一次报得出、一次报不出(`UNKNOWN_SIZE`),那不影响
|
||||
* 「这是同一份文件」。决定内容身份的是**本地引用本身**,所以指纹只取它。
|
||||
* 顺序照旧有意义(附件的先后就是用户在芯片区看到的先后),因此是列表而不是集合。
|
||||
*/
|
||||
data class MessageFingerprint(
|
||||
val text: String,
|
||||
/** 图片的**本地可读** URI;null = 这条不带图(与待发消息的字段一一对应)。 */
|
||||
val imageUri: String? = null,
|
||||
/** 附件的本地 URI,顺序即用户在芯片区看到的顺序。 */
|
||||
val documentUris: List<String> = emptyList(),
|
||||
) {
|
||||
companion object {
|
||||
/** 从发送那一刻的三件套算指纹(正文 / 图片 / 待发附件)。 */
|
||||
fun of(text: String, imageUri: String?, documents: List<DraftDocument>): MessageFingerprint =
|
||||
MessageFingerprint(text = text, imageUri = imageUri, documentUris = documents.map { it.uri })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 忙时并行回合(多路)的客户端模型:**分桶、六态、灰态收口**三件纯逻辑。
|
||||
*
|
||||
* 规格来源:`Cyrene_v2/DevDocs/_specs/忙时并行-规范草案.md`(§1 行为规范 / §2 接口规范 / §4 主人裁决)。
|
||||
* 本文件**只有纯数据与纯函数**:不碰协程、不碰 Android、不碰网络,所以三层逻辑都能直接单测。
|
||||
* 消费方是 `ChatViewModel`(接线)与 `ChatScreen` / `MessageBubble`(渲染)。
|
||||
*
|
||||
* ## 为什么要分桶
|
||||
* 两路并行时,同一 `sessionId` 下**两个不同 `turnId`** 的 `message.delta` / `thinking.delta` /
|
||||
* `tool_call.*` 会**交错到达**。若照老代码那样「一律累加到当前那一个流式气泡」,两条回复会被糊成
|
||||
* 一条(规范 §1.4 明写这一点)。所以落桶键是 `(sessionId, turnId)` 两级(规范 §1.7 第 1 条)。
|
||||
*
|
||||
* ## 字段缺失必须能降级(本文件的头号纪律)
|
||||
* `turnId` 在真后端 v0.36 起就打在**每个结构化流帧**的帧壳上,但本文件**不假设**它一定在:
|
||||
* 帧壳没给(`null` / 空白)时,帧落回**本会话最近见过的那条路**([turnBucketOf] 的 `sessionTurnId`),
|
||||
* 一条都还没见过时落进**匿名桶**(`turnId == ""`)。于是「所有帧都不带 `turnId`」时全部帧落进
|
||||
* **同一个桶**——也就是**今天那一个流式气泡**,行为逐字相同。
|
||||
* 同理 `turn_begin` / `steer_ack` 不来时,六态里只会出现 ①②③⑤,灰态(④)根本不会进入。
|
||||
* `kind` 不来时(老服务端)也只是**不挂并行路角标**——[isParallelLegKind] 认不出来就说不并行,
|
||||
* 绝不拿 `leg` 去替它撑门面。
|
||||
*/
|
||||
|
||||
/** 「路序号未知」:`steer_ack` 只给 `turnId`、不给 `leg`(规范 §2.5 的载荷只有 messageId / atStep)。 */
|
||||
const val UNKNOWN_LEG: Int = 0
|
||||
|
||||
/**
|
||||
* `turn_begin.kind` 里表示「忙时另起的一路」的取值(规范 §2.4)。
|
||||
*
|
||||
* 另一个取值 `"turn"` 表示「新回合 / 续跑」——也就是**普通串行回合**,它不在这里单列常量:
|
||||
* 本端只认得出 `"parallel"` 这一个,其余一切取值(含 `"turn"`)都按「不是并行」处理
|
||||
* (见 [isParallelLegKind] 的 KDoc)。
|
||||
*/
|
||||
private const val PARALLEL_LEG_KIND = "parallel"
|
||||
|
||||
/**
|
||||
* 流式分桶键:`sessionId` → `turnId` 两级(规范 §1.4 / §1.7 第 1 条)。
|
||||
*
|
||||
* [turnId] 为空串 = **匿名桶**:帧壳没给 `turnId`、且本会话还没见过任何 `turnId` 时的落点。
|
||||
* 它是「老的单路行为」在分桶模型里的等价物——所有匿名帧落在这同一个桶里,与今天逐字相同。
|
||||
*/
|
||||
data class TurnBucket(val sessionId: String, val turnId: String) {
|
||||
|
||||
/** 是否匿名桶(帧壳没给 `turnId`)。 */
|
||||
val isAnonymous: Boolean get() = turnId.isEmpty()
|
||||
|
||||
override fun toString(): String = if (isAnonymous) "$sessionId/*" else "$sessionId/$turnId"
|
||||
}
|
||||
|
||||
/**
|
||||
* 帧该落哪个桶(纯函数)。
|
||||
*
|
||||
* 优先级:**帧壳给的 `turnId`** > **本会话最近见过的 `turnId`** > **匿名桶**。
|
||||
*
|
||||
* 第二条是「字段缺失 ⇒ 行为与今天逐字相同」那条硬要求的落地处:帧壳没给 `turnId` 时,帧跟着
|
||||
* 本会话当前那一路走——老代码里它本来就会落进那**唯一**一个流式气泡。因此
|
||||
* `turnBucketOf("web", null, null)` 恒等于匿名桶,全部帧都落在一处,与分桶之前完全一致。
|
||||
*
|
||||
* @param frameTurnId 帧壳上的 `turnId`(可能 `null` / 空白)。
|
||||
* @param sessionTurnId 本会话最近一次见到的**非空** `turnId`(由调用方记住并喂进来)。
|
||||
*/
|
||||
fun turnBucketOf(sessionId: String, frameTurnId: String?, sessionTurnId: String?): TurnBucket =
|
||||
TurnBucket(sessionId, frameTurnId.orNullIfBlank() ?: sessionTurnId.orNullIfBlank() ?: "")
|
||||
|
||||
/**
|
||||
* **收口帧**(`stream_end` / `turn.end` / 落库的助手行)该落哪个桶(纯函数)。
|
||||
*
|
||||
* 与 [turnBucketOf] 的区别只有一条、但很要紧:**收口帧不许新开桶**。
|
||||
*
|
||||
* 理由是实证的:老代码里 `stream_end` 无论带着哪个 `turnId`,都会把**唯一**那个流式气泡收口
|
||||
* (`active = false`)。若照 [turnBucketOf] 给一个认不出来的 `turnId` 开新桶,那个新桶收口、
|
||||
* **真正在跑的那个桶永远收不了口**——光标一直转、`stream_end` 这一帧等于白给。
|
||||
* 所以这里的顺序是:
|
||||
*
|
||||
* 1. 帧壳的 `turnId` 认得出来(在 [existing] 里)⇒ 就是它;
|
||||
* 2. 否则本会话最近见过的 `turnId` 认得出来 ⇒ 用它;
|
||||
* 3. 否则 [existing] 里还有桶 ⇒ 用**最近更新的那一个**(单路场景下它就是正确的那一个);
|
||||
* 4. 一个桶都没有 ⇒ 匿名桶(也不新开,调用方据此可以对「没有在跑的路」整帧跳过)。
|
||||
*
|
||||
* @param existing 当前**已经存在**的桶的 `turnId` 列表,**顺序即更新时间**(最后的最近)。
|
||||
*/
|
||||
fun closingBucketOf(
|
||||
sessionId: String,
|
||||
frameTurnId: String?,
|
||||
sessionTurnId: String?,
|
||||
existing: List<String>,
|
||||
): TurnBucket {
|
||||
val frame = frameTurnId.orNullIfBlank()
|
||||
val session = sessionTurnId.orNullIfBlank()
|
||||
val target = when {
|
||||
frame != null && existing.contains(frame) -> frame
|
||||
session != null && existing.contains(session) -> session
|
||||
existing.isEmpty() -> ""
|
||||
else -> existing.last()
|
||||
}
|
||||
return TurnBucket(sessionId, target)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条消息在「忙时并行」语义下可能处的**六态**(规范 §1.1)。
|
||||
*
|
||||
* 名字逐字对齐规范表格里的态名,避免两套叫法:
|
||||
* - [Sending] ① 发送中(本地乐观气泡);
|
||||
* - [Sent] ② 已送达(正常气泡,还没进任何一路);
|
||||
* - [Queued] ③ 排队中(气泡角标「排队中」**浅灰**——规范 §1.3 明写:**`queued` 不是灰态**,
|
||||
* `pending_ack` 不许渲染成灰态,`queued` 只靠 `turn_begin` **直接换态**);
|
||||
* - [Answering] ④ 已受理·回答中(**灰态「已读、未答」**);
|
||||
* - [Answered] ⑤ 已答(终态);
|
||||
* - [Rejected] ⑥ 被拒(终态,用户可手动重发)。
|
||||
*/
|
||||
enum class TurnPhase { Sending, Sent, Queued, Answering, Answered, Rejected }
|
||||
|
||||
/**
|
||||
* 六态判定(纯函数,规范 §1.1 的表格逐行对照)。
|
||||
*
|
||||
* 判据只有四个,且**顺序即证据强度**(与 `queueFullOutcome` 同一条纪律):
|
||||
* 1. [rejected] = 收到过明确拒绝(定向 `error{chat_busy}` / 广播 `pending_full`)⇒ ⑥。
|
||||
* 它压过一切:这条消息**根本没进去**,不许再拿「有没有被接手」去描述它;
|
||||
* 2. [binding] 非空且**未收口** ⇒ ④ 灰态(`turn_begin` 的 `messageIds` 含它,或 `steer_ack` 绑上它);
|
||||
* 3. [binding] 非空且**已收口** ⇒ ⑤;
|
||||
* 4. [binding] 为空且是本地乐观气泡 ⇒ [queued] ? ③ : ①;
|
||||
* 5. [binding] 为空且已落库 ⇒ [queued] ? ③ : ②。
|
||||
*
|
||||
* 第 4/5 条里 [queued] 与非 [queued] 分开,是因为服务端的 `pending_ack` **不带消息身份**
|
||||
* (规范 §1.1 ③ 的进入条件明写「现状帧,形状不变」)——客户端只能按「这条我发出去之后收到了入队
|
||||
* 回执」来认,认不出来时为 [Sent] / [Sending](今天的样子),**绝不瞎猜成排队**。
|
||||
*
|
||||
* @param local 这条是不是**本地乐观气泡**(还没落库)。
|
||||
* @param binding 这条消息被哪一路接手(`null` = 没被任何一路接手)。
|
||||
* @param queued 收到过「它进队列」的回执(`pending_ack` / REST `202 queued`)。
|
||||
* @param rejected 收到过明确的拒绝。
|
||||
*/
|
||||
fun turnPhaseOf(
|
||||
local: Boolean,
|
||||
binding: TurnBinding?,
|
||||
queued: Boolean,
|
||||
rejected: Boolean,
|
||||
): TurnPhase = when {
|
||||
rejected -> TurnPhase.Rejected
|
||||
binding != null && !binding.closed -> TurnPhase.Answering
|
||||
binding != null -> TurnPhase.Answered
|
||||
local -> if (queued) TurnPhase.Queued else TurnPhase.Sending
|
||||
queued -> TurnPhase.Queued
|
||||
else -> TurnPhase.Sent
|
||||
}
|
||||
|
||||
/**
|
||||
* 灰态(④)**收口判据**(纯函数,规范 §1.2)。
|
||||
*
|
||||
* 规范给的是**唯一口径**:收口事件 = 该消息所属回合的 `turn.end`,用 `turn.end` **帧壳的 `turnId`**
|
||||
* 与这条消息绑定的 `turnId` 比对,**相等即收口**;此外该 `turnId` 的助手 `message_persisted`
|
||||
* 同样收口(两者顺序不定,任一到即收口)。
|
||||
*
|
||||
* 所以这里就一件事:两个 id **都非空且相等**。任一为空 ⇒ `false`(**不许**用「都为空所以相等」
|
||||
* 收口——那会让「没有 turnId 的老客户端 / 老服务端」上的消息被随便一帧误收口)。
|
||||
*
|
||||
* @param boundTurnId 这条消息绑定的 `turnId`。
|
||||
* @param closingTurnId 收口帧带来的 `turnId`(`turn.end` 帧壳 / 助手落库行的 `turnId`)。
|
||||
*/
|
||||
fun closesAnswering(boundTurnId: String?, closingTurnId: String?): Boolean {
|
||||
val bound = boundTurnId.orNullIfBlank() ?: return false
|
||||
val closing = closingTurnId.orNullIfBlank() ?: return false
|
||||
return bound == closing
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息 ↔ 回合的绑定:**一路**(规范 §2.4 的 `turn_begin` 说的是「编排器每次启动一路发一次」)。
|
||||
*
|
||||
* 它由两件事建立:`turn_begin`(一次给全 `messageIds` / `leg` / `activeTurns` / `kind`)与 `steer_ack`
|
||||
* (只补「某条消息被哪一路吃进」,没有 `leg`)。
|
||||
* 只在 `turnId` **非空**时才会被建立——`turnId` 是这张表的键,也是收口的唯一凭据(见
|
||||
* [closesAnswering]);没有它建出来的绑定永远收不了口,那不是绑定,是一个永远灰着的气泡。
|
||||
*
|
||||
* ## [leg] 与 [parallel] 是两件事(这张表最容易被读错的一处)
|
||||
* [leg] 只是**会话内第几条路**:服务端 `beginLegLocked` 对**每一条新路**都自增 `legSeq`,普通串行
|
||||
* 回合照样占号——会话里第二条普通消息的 `leg` 就是 2。所以 `leg >= 2` **完全不等于**并行,拿它当
|
||||
* 并行的证据会挂出并不存在的「并行路 N」。
|
||||
* 真正说明「这一路是忙时另起的一路」的只有 `turn_begin.kind`,落到 [parallel](见 [isParallelLegKind])。
|
||||
*/
|
||||
data class TurnBinding(
|
||||
/** 这一路的权威回合标识(与该路全部流帧的 `turnId` 同值)。 */
|
||||
val turnId: String,
|
||||
/** 该路的会话内序号([UNKNOWN_LEG] = `steer_ack` 建的表,服务端没告诉序号)。 */
|
||||
val leg: Int = UNKNOWN_LEG,
|
||||
/** 归属这一路的**用户消息 id**(`turn_begin.messageIds` / `steer_ack.messageId`)。 */
|
||||
val messageIds: List<String> = emptyList(),
|
||||
/** 服务端最近一次报的该会话在途路数(`turn_begin.activeTurns`;0 = 没报过)。 */
|
||||
val activeTurns: Int = 0,
|
||||
/** 这一路是不是**真并行路**(服务端 `turn_begin.kind == "parallel"`,见 [isParallelLegKind])。 */
|
||||
val parallel: Boolean = false,
|
||||
/** 该路是否已收口(`turn.end` / 同 `turnId` 的助手落库行)。 */
|
||||
val closed: Boolean = false,
|
||||
)
|
||||
|
||||
/** 这条消息被哪一路接手;`null` = 没被任何一路接手(也是「按顺序渲染」那档的老行为)。 */
|
||||
fun List<TurnBinding>.bindingOfMessage(messageId: String): TurnBinding? =
|
||||
firstOrNull { binding -> messageId.isNotBlank() && binding.messageIds.contains(messageId) }
|
||||
|
||||
/**
|
||||
* `turn_begin.kind` ⇒ 「这一路是不是并行路」(纯函数,规范 §2.4)。
|
||||
*
|
||||
* 只有**逐字等于** `"parallel"` 才算并行:`"turn"`(新回合 / 续跑)不算,缺席 / 空串 / 大小写不同 /
|
||||
* 以后新增的其它取值也一律不算。两侧空白按缺席的写法一并容忍(与 `parallelLimitNoticeFor` 认
|
||||
* `pending_ack.reason` 同一条口径),大小写**不**容忍——认不出来就是认不出来。
|
||||
*
|
||||
* **为什么认不出来就算「不是并行」**:`kind` 是服务端后加的键,本端必须能在**老服务端**(整键不给)
|
||||
* 上跑,而 [TurnBinding.leg] 又**根本不足以**当判据(它是会话内第几条路,串行回合照样占号)。
|
||||
* 两件事叠起来只剩一个如实的答案:认不出来就说「不知道」,而「不知道」在界面上等于**不挂角标**
|
||||
* ——宁可少挂一枚,也不能挂一枚声称存在并不存在的并行的标。
|
||||
*/
|
||||
fun isParallelLegKind(kind: String?): Boolean = kind?.trim() == PARALLEL_LEG_KIND
|
||||
|
||||
/**
|
||||
* 收下一条 `turn_begin`(纯函数,返回新表)。
|
||||
*
|
||||
* 同 [TurnBinding.turnId] 已存在时**合并**而不是新增:`messageIds` 取并集(保持原顺序在前),
|
||||
* `leg` / `activeTurns` 用新报的值,**`closed` 保持原值**——一次重复到达的 `turn_begin` 不该把一条
|
||||
* 已经收口的路「重新打开」(那会让气泡回到灰态并且再也等不到第二次收口)。
|
||||
* 表里没有它时按**追加**处理:两路的顺序就是它们被启动的顺序。
|
||||
*
|
||||
* ## [TurnBinding.parallel] 的合并规则:一个只说真话的**单调**事实
|
||||
* 新建时它 = [kind] 是不是 `"parallel"`(见 [isParallelLegKind],认不出来 ⇒ `false`);
|
||||
* 已存在时取**或**(`old.parallel || 本帧是并行`)——**一旦为 true 就永远是 true**。
|
||||
*
|
||||
* 三条理由:
|
||||
* 1. 「这一路是被并行起出来的」是**既成事实**:服务端同一条路的 `kind` 由创建那一刻的标志决定、
|
||||
* 之后不可变,所以重复帧本不该各说各话;真各说各话时以「说过 parallel」为准——它不会凭一个
|
||||
* **更弱**(认不出 `kind`)的重复帧把已确立的并行抹掉,而并行只会多挂一枚有据可依的角标;
|
||||
* 2. 与 [TurnBinding.closed] 同一条纪律:**单调的既成事实不许被后到的帧翻回去**;
|
||||
* 3. 现实里真有「先弱后强」的顺序:[withSteeredMessage] 可能先按 `steer_ack` 建一条占位表
|
||||
* (那时没有 `kind`,只能是 `false`),随后那一路自己的 `turn_begin` 才是唯一能把它标成并行的地方。
|
||||
*
|
||||
* [kind] 缺席(默认空串)= 老服务端 / 老形状的帧 ⇒ 这一路永远 `false` ⇒ 不挂角标,与今天可解释地
|
||||
* 一致(少一枚角标,但没有多说一句假话)。
|
||||
*/
|
||||
fun List<TurnBinding>.withTurnBegin(
|
||||
turnId: String,
|
||||
leg: Int,
|
||||
messageIds: List<String>,
|
||||
activeTurns: Int,
|
||||
kind: String = "",
|
||||
): List<TurnBinding> {
|
||||
val key = turnId.orNullIfBlank() ?: return this
|
||||
val parallel = isParallelLegKind(kind)
|
||||
val index = indexOfFirst { it.turnId == key }
|
||||
if (index < 0) {
|
||||
return this + TurnBinding(
|
||||
turnId = key,
|
||||
leg = leg,
|
||||
messageIds = messageIds.filter(String::isNotBlank),
|
||||
activeTurns = activeTurns,
|
||||
parallel = parallel,
|
||||
)
|
||||
}
|
||||
val old = this[index]
|
||||
val merged = old.copy(
|
||||
leg = leg.takeIf { it > UNKNOWN_LEG } ?: old.leg,
|
||||
messageIds = (old.messageIds + messageIds.filter(String::isNotBlank)).distinct(),
|
||||
activeTurns = activeTurns.coerceAtLeast(0),
|
||||
parallel = old.parallel || parallel,
|
||||
)
|
||||
return toMutableList().also { it[index] = merged }
|
||||
}
|
||||
|
||||
/**
|
||||
* 收下一条 `steer_ack`(纯函数,返回新表):把 [messageId] 绑到 [turnId] 这一路上——这就是
|
||||
* 「这条消息被在飞的那一轮吃进去了,它进灰态」的凭据(规范 §2.5)。
|
||||
*
|
||||
* 该 `turnId` 还没有表项时**建一个只有这一条消息的表**(`steer_ack` 不给 `leg` / `activeTurns`,
|
||||
* 所以那两项留 [UNKNOWN_LEG] / 0)。`turnId` 或 `messageId` 空白 ⇒ **原样返回**:
|
||||
* 绑不出东西来(见 [TurnBinding] 的 KDoc)。
|
||||
*/
|
||||
fun List<TurnBinding>.withSteeredMessage(turnId: String, messageId: String): List<TurnBinding> {
|
||||
val key = turnId.orNullIfBlank() ?: return this
|
||||
val message = messageId.orNullIfBlank() ?: return this
|
||||
val index = indexOfFirst { it.turnId == key }
|
||||
if (index < 0) return this + TurnBinding(key, UNKNOWN_LEG, listOf(message))
|
||||
val old = this[index]
|
||||
if (old.messageIds.contains(message)) return this
|
||||
return toMutableList().also { it[index] = old.copy(messageIds = old.messageIds + message) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 收口(纯函数,返回新表):把 [closingTurnId] 那一路上所有消息的绑定标记为 `closed`。
|
||||
*
|
||||
* 判据就是 [closesAnswering](两个 id 都非空且相等),因此一个空白 / 认不出来的 `closingTurnId`
|
||||
* **一条都不收口**——这正是老服务端(没有 `turnId`)上的行为:什么都不发生。
|
||||
* 没有命中任何一条时**原样返回同一个实例**(Compose 的稳定性与测试里的 `assertSame` 靠它)。
|
||||
*/
|
||||
fun List<TurnBinding>.closingTurn(closingTurnId: String?): List<TurnBinding> {
|
||||
var changed = false
|
||||
val next = map { binding ->
|
||||
if (!binding.closed && closesAnswering(binding.turnId, closingTurnId)) {
|
||||
changed = true
|
||||
binding.copy(closed = true)
|
||||
} else {
|
||||
binding
|
||||
}
|
||||
}
|
||||
return if (changed) next else this
|
||||
}
|
||||
|
||||
/**
|
||||
* 把**全部**在途路收口(纯函数):`session_idle` 的规范语义是「所有在途路都已收尾且 pending 空」
|
||||
* (规范 §1.5),所以那一帧到达时**不可能**还有没收口的路——本函数就是这条语义的兜底落地。
|
||||
* 它是兜底而不是主路径:主路径仍是各自 `turnId` 的 `turn.end`(规范 §1.2)。
|
||||
*/
|
||||
fun List<TurnBinding>.allClosed(): List<TurnBinding> {
|
||||
if (none { !it.closed }) return this
|
||||
return map { if (it.closed) it else it.copy(closed = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 路标文案(规范 §1.4 A 的「小标」):**真的是并行路**且 `leg >= 2` 时才成句,其余返回 `null`。
|
||||
*
|
||||
* 两个条件各挡一类错误,缺一不可:
|
||||
* - [parallel] 为 `false` ⇒ **恒不挂**,哪怕 `leg` 是 2、3、99。`leg` 是**会话内第几条路**
|
||||
* (服务端对每条新路都自增,普通串行回合照样占号),拿它当并行的证据就是**对用户说谎**:
|
||||
* 会话里第二条普通消息会凭空冒出一枚「并行路 2」,而那一刻一条并行都没有。是不是并行路
|
||||
* 只有 `turn_begin.kind`(⇒ [TurnBinding.parallel])说了算;
|
||||
* - [leg] 小于 2(含 [UNKNOWN_LEG])⇒ 不挂:单路是今天的常态(`parallel:true` 是客户端显式声明
|
||||
* 才有的能力,规范 §0 的默认行为一条),给唯一一路也挂个「并行路 1」等于给每条回复都加一个新的
|
||||
* 视觉噪音,却什么都说明不了。所以「有没有小标」本身就精确表达「这里是不是真的有多路」。
|
||||
* `leg` 未知同理不挂——不能把「不知道第几路」说成「第 0 路」。
|
||||
*/
|
||||
fun legBadgeOf(leg: Int, parallel: Boolean): String? =
|
||||
if (parallel && leg >= 2) "并行路 $leg" else null
|
||||
|
||||
/** 空白串等同于缺席(服务端是「整键缺席」,但真给空串时按同一件事处理,与既有各纯函数一致)。 */
|
||||
private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
|
||||
@@ -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,177 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 一次「没发出去」留下的记录:**那一条**的内容指纹 + 当时铸的幂等键(`localId`)+ 记下它的时刻。
|
||||
*
|
||||
* [atMillis] 只服务于 [RetryKeyMemory.SLOT_TTL_MILLIS] 那道时间闸,与键本身的语义无关:没有它,一条
|
||||
* 几小时前的失败记录会一直活着,用户**新**打的一句同内容的话就会被当成重试(见 [RetryKeyMemory])。
|
||||
*/
|
||||
data class RetryKeySlot(
|
||||
val fingerprint: MessageFingerprint,
|
||||
val localId: String,
|
||||
val atMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* [RetryKeyMemory.keyFor] 的判定结果:这一次发送该用哪个幂等键,以及记忆变成什么样。
|
||||
*
|
||||
* 两半都是判定的一部分,所以一起回:**命中的那一条**会被消费掉(见 [RetryKeyMemory.keyFor]),
|
||||
* 把它放进返回值,调用方就没有「用完了忘了清」的机会。
|
||||
*/
|
||||
data class RetryKeyDecision(val localId: String, val memory: RetryKeyMemory)
|
||||
|
||||
/**
|
||||
* 「没发出去的那几条」的记忆——**多槽**、**有时间上界**、纯值、可单测。
|
||||
*
|
||||
* ## 它存在的理由
|
||||
* 幂等键要想真的挡住重复,就必须在**同一条消息**的两次发送之间保持不变。而「某次失败」并不
|
||||
* 等于「服务端没收下」:队列满(REST `409` / 定向 `chat_busy`)是**真的没进去**,但超时、
|
||||
* 回执丢失、连接被踢完全可能是「服务端已经落库了,只是客户端没看见」。用户手动重试时若铸一个
|
||||
* 新键,服务端就会把它当成**第二条**消息落库 —— 用户看到重复。这里记下失败那次的指纹与键,
|
||||
* 让「一模一样的内容再发一次」落回同一个键,由服务端用 `chat_duplicate` /
|
||||
* `{duplicate:true,messageId}` 替本端裁决。
|
||||
*
|
||||
* ## 时间上界:10 分钟,为什么多槽必须配一个时钟
|
||||
* 记录是**跨很多次发送**存活的(这正是多槽的意义,见下面「为什么是多槽」),于是有了一个单槽时代
|
||||
* 不存在的窗口:用户**几小时前**失败过一句「你好」,之后**新**打一遍「你好」—— 他心里当的是一条
|
||||
* **新**消息,指纹却与那条旧记录一字不差 ⇒ [keyFor] 命中 ⇒ 复用旧键 ⇒ 服务端回 `chat_duplicate`
|
||||
* ⇒ 这条新消息**根本不发出去**,屏幕上看着像「按了没反应」。给每个槽加一道时间闸
|
||||
* [SLOT_TTL_MILLIS],这个窗口就被钉死在 10 分钟内;而真正需要幂等的那一幕 ——「刚失败完,用户马上
|
||||
* 又按了一次发送」—— 一秒都没被削掉。
|
||||
*
|
||||
* 时钟由调用方喂([remember] 的 `atMillis`、[keyFor] 的 `nowMillis`),纯值类型里**不藏**
|
||||
* `System.currentTimeMillis()`:判据才可单测,也才不用为了「等 10 分钟」写 sleep。
|
||||
*
|
||||
* ## 边界口径:正好等于 TTL **不算**过期
|
||||
* 判据是 `nowMillis - atMillis > SLOT_TTL_MILLIS`(严格大于),所以 `nowMillis == atMillis + TTL`
|
||||
* 那一条**仍然命中**,真正过期要再晚一毫秒。选这一边的理由:常量自身就能当「仍在窗口内」的边界值
|
||||
* 直接断言(测试不必为「还活着」那一侧发明魔法数),过期那一侧才需要显式的 `+1`;而真实时钟几乎
|
||||
* 不可能精确落在这 1 毫秒上,口径选哪边都没有实害,可测的那边更有价值。测试
|
||||
* `a slot exactly at the TTL boundary still counts as fresh` 把这条钉住。
|
||||
*
|
||||
* ## 三条纪律
|
||||
* 1. **只在回滚时写**([remember]:发送失败 / 队列满被拒 / 附件与图片上传失败 / 图片处理失败…):
|
||||
* 被受理的那条没有任何要记的东西。
|
||||
* 2. **在用户下一次发送时按指纹取用**([keyFor]):指纹相等 ⇒ 复用那个键、**且只消费那一条**;
|
||||
* 不等 ⇒ 铸新键、**一条槽也不清**(多槽下「不等也清」是错的,见 [keyFor] 的注释)。
|
||||
* 3. **按 `localId` 清**([forget]),不是无条件清空:几条同时在飞时,一条被受理(它的键与别的槽
|
||||
* 里记的那条对不上)不该把别条刚记下的重试键抹掉。
|
||||
*
|
||||
* ## 为什么是「多槽」:单槽的边界
|
||||
* 单槽只记得**最近一次**失败那条,而用户的重试顺序不必和失败顺序一致:他重试 A(又失败),再回头
|
||||
* 去重试更早失败的 B —— B 的槽早被 A 顶掉了,那次重发只能铸新键([keyFor] 未命中),于是
|
||||
* 「服务端其实已经收下」的那条 B 落成**第二条**消息,正是本机制要消灭的重复。多槽让每条失败各留
|
||||
* 一个槽,用户重试哪一条就落回哪一条的键,与他的操作顺序无关。
|
||||
*
|
||||
* ## 上限与淘汰:条数上限 8 + 时间上界 10 分钟,两条规则同时生效
|
||||
* 一条记录只在「用户可能还要重试」的窗口里有意义:失败后正文 / 图片 / 附件都还回了输入栏,他要么
|
||||
* 马上原样重发、要么早就改了内容(那就成了另一条消息,记录永远命中不了)。窗口外留下的只是垃圾,
|
||||
* 所以有两把尺子:条数上限 [MAX_SLOTS](满了丢**最旧**的,[remember] 里 FIFO)与时间上界
|
||||
* [SLOT_TTL_MILLIS](比上限还旧的那条,无论排第几都作废)。
|
||||
*
|
||||
* **顺序是死的**:[remember] / [keyFor] **先按时间淘汰**过期槽,再做自己那件事(去重追加 / 查命中),
|
||||
* 写入时**最后**才看条数上限。先按时间清的好处是过期槽不占那份本该给新鲜记录的额度 —— 否则 8 个
|
||||
* 早已过期的槽会把刚写进来的那条挤出去(或者更糟:让 [keyFor] 在一个条数已满、内容却全过期的记忆里
|
||||
* 报「满了」)。
|
||||
*
|
||||
* 刻意**不**做 LRU:命中并消费是**轮询**而不是访问 —— [keyFor] 把命中的那条整个摘走,与它有多旧
|
||||
* 无关(只要求没过期),谈「最近使用」没有意义;也正因如此,剩下的槽天然还是写入顺序。
|
||||
*
|
||||
* ## 与「绝不自动重试」不矛盾
|
||||
* 这里一个字节都不自动重发:复用键只发生在**用户自己又按了一次发送**、且内容一字不差的时候。
|
||||
* 它做的唯一一件事是把那次重发变成幂等的。
|
||||
*/
|
||||
data class RetryKeyMemory(val slots: List<RetryKeySlot> = emptyList()) {
|
||||
|
||||
/**
|
||||
* 记下「这一条没发出去」(回滚时调用)。
|
||||
*
|
||||
* 三件事按固定顺序做,别调换(理由见类注释「上限与淘汰」):
|
||||
* 1. **先按 [atMillis] 淘汰过期槽**——[atMillis] 既是这条记录的出生时刻,也是「现在」;
|
||||
* 2. 同一个指纹**先摘掉旧的、再把新的追加到末尾**:同一条又失败一次是**更新**它的键与时刻,
|
||||
* 而不是并列两条 —— 两条同指纹时 [keyFor] 只会命中更早那条,用户的重试反而用不上刚铸的键,
|
||||
* 而且那条更早的还会**先**过期(明明刚失败过,记录却已经超时,重试退回铸新键);
|
||||
* 3. 超过 [MAX_SLOTS] 就从头部丢最旧的:本函数最多让**末尾**那条变新,淘汰因此绝不会伤到刚写的
|
||||
* 这条。
|
||||
*
|
||||
* @param atMillis 这次失败发生的时刻,由调用方喂(`System.currentTimeMillis()`)。
|
||||
*/
|
||||
fun remember(fingerprint: MessageFingerprint, localId: String, atMillis: Long): RetryKeyMemory {
|
||||
val fresh = purge(atMillis)
|
||||
val kept = fresh.slots.filterNot { it.fingerprint == fingerprint } +
|
||||
RetryKeySlot(fingerprint, localId, atMillis)
|
||||
return RetryKeyMemory(if (kept.size > MAX_SLOTS) kept.takeLast(MAX_SLOTS) else kept)
|
||||
}
|
||||
|
||||
/**
|
||||
* 忘掉 [localId] 那条——受理成功 / 撞上 `duplicate` / 气泡被收编时调用。
|
||||
*
|
||||
* 一条都对不上就**原样返回同一个实例**(一条也不清):槽里躺的多半是别的刚回滚的消息,顺手清掉
|
||||
* 它们会让那几条的重试退回「铸新键」。同一个 `localId` 万一有多条([remember] 保证不会有),
|
||||
* 一并清掉:留着一条键已被服务端受理过的记录,只会让将来的某次重发被当成重复吞掉。
|
||||
*
|
||||
* **与时间无关**:受理回执说的是「这条键服务端认了」,无论它记于几分钟前。此处也不顺手扫过期槽
|
||||
* —— 那要一个调用方没给的「现在」,而且过期槽本来就命中不了([keyFor] 入口会清掉它们),留着
|
||||
* 最多是几个死条目的内存占用。
|
||||
*/
|
||||
fun forget(localId: String): RetryKeyMemory {
|
||||
val kept = slots.filterNot { it.localId == localId }
|
||||
return if (kept.size == slots.size) this else RetryKeyMemory(kept)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一次发送该用哪个幂等键:内容与某条**未过期**的记录一致 ⇒ **复用**它的键,并**只消费那一条**
|
||||
* (其余槽原样保留 —— 它们对应的是别的失败消息,正等着被各自的重试捡起来);一条都对不上 ⇒
|
||||
* 铸一个新的([mint] 由调用方给,测试才能喂确定性的键,也才能断言「该不该铸」),**且一条槽也不清**。
|
||||
*
|
||||
* 入口**先**按 [nowMillis] 淘汰过期槽,过期的那条**视同不存在**:找不到就铸新键,与「从来没记过」
|
||||
* 这条完全一样。这正是时间上界要挡住的那一幕(见类注释「时间上界」)—— 几小时前失败过的同内容
|
||||
* 消息,今天再打一遍就是一条**新**消息,绝不能因为撞上旧键而被服务端当成重复吞掉。
|
||||
*
|
||||
* 最后这半句是与单槽时代最本质的差别,**别照着旧注释改回去**:那时记忆只有一条,「未命中就清空」
|
||||
* 与「保留」是同一件事;多槽下清空会把用户**别的**失败记录一并抹掉(他重试 A 时铸了新键,接着去
|
||||
* 重试 B,B 的槽已经没了 ⇒ 那次重发退化成非幂等)。未命中只说明「这次的正文没有任何失败记录对得
|
||||
* 上」,不是「记忆都作废了」。(过期槽被摘掉不算例外:它们本来就再也命中不了。)
|
||||
*
|
||||
* @param nowMillis 这次发送的时刻,由调用方喂(`System.currentTimeMillis()`)。
|
||||
*/
|
||||
fun keyFor(
|
||||
fingerprint: MessageFingerprint,
|
||||
nowMillis: Long,
|
||||
mint: () -> String,
|
||||
): RetryKeyDecision {
|
||||
val fresh = purge(nowMillis)
|
||||
val hitIndex = fresh.slots.indexOfFirst { it.fingerprint == fingerprint }
|
||||
if (hitIndex < 0) return RetryKeyDecision(mint(), fresh)
|
||||
|
||||
val consumed = RetryKeyMemory(fresh.slots.filterIndexed { index, _ -> index != hitIndex })
|
||||
return RetryKeyDecision(fresh.slots[hitIndex].localId, consumed)
|
||||
}
|
||||
|
||||
/**
|
||||
* 摘掉在 [nowMillis] 这一刻已经过期的槽;一条都没过期就**原样返回同一个实例**(不必新建对象)。
|
||||
*
|
||||
* 过期判据见类注释「边界口径」——正好等于 [SLOT_TTL_MILLIS] 的那条**留着**。
|
||||
*/
|
||||
private fun purge(nowMillis: Long): RetryKeyMemory {
|
||||
val kept = slots.filterNot { it.isExpiredAt(nowMillis) }
|
||||
return if (kept.size == slots.size) this else RetryKeyMemory(kept)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 同时保留的失败记录条数上限;淘汰策略见类注释「上限与淘汰」。 */
|
||||
const val MAX_SLOTS = 8
|
||||
|
||||
/**
|
||||
* 一条失败记录最多活多久(10 分钟):超过它,[keyFor] 就再也认不出这条记录。
|
||||
*
|
||||
* 公开是因为**测试拿它做断言**(别在测试里重写一遍 `10 * 60 * 1000L`,那样改口径时测试不会
|
||||
* 跟着变)。为什么需要这道闸、边界怎么算,见类注释「时间上界」与「边界口径」。
|
||||
*/
|
||||
const val SLOT_TTL_MILLIS = 10 * 60 * 1000L
|
||||
|
||||
/** 这条记录在 [nowMillis] 这一刻是否已过期;`>` 而不是 `>=`,见类注释「边界口径」。 */
|
||||
private fun RetryKeySlot.isExpiredAt(nowMillis: Long): Boolean =
|
||||
nowMillis - atMillis > SLOT_TTL_MILLIS
|
||||
}
|
||||
}
|
||||
@@ -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,15 +14,48 @@ 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>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
|
||||
* 它们本就是服务端产出的标记、不是她说的话;渲染成正文等于往气泡里塞一串用户既看不懂、
|
||||
* 也改不掉的字符,而剥掉只是少显示几个本就不该出现的字符,正文一个字都不动。
|
||||
* 这只是客户端的容错,不代表服务端这样发是对的。
|
||||
* 判据([stripLooseTags])分两种情形:消息里**已有完整块**时,段落结构已被完整块定死,块内
|
||||
* 块外多出来的每个 `<p>` / `</p>`、以及块外孤立的旁白标签,都是冗余噪声,一律剥掉(块内的
|
||||
* 落单旁白标签例外,见最后一条);消息里**一个完整块都没有**时,按上一条整段回落字面文本,
|
||||
* 只再剥两类连「被截断的结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。
|
||||
* 只认「确定是标签」的形状:`<p>` / `<p 属性>` / `</p>` / `<action>` 等。`1 < 2`、`<3`、
|
||||
* `a<b`、`<p2>`、`<P>`(词表只有小写,与 `MessageEditText` 同一口径)、`<p`(残缺,没有 `>`)
|
||||
* 都原样保留——见到 `<` 就吃,才是真正会丢字的那种错。
|
||||
* - 未闭合/畸形的其余标签一律回落为普通文本,绝不抛异常。块内没配对的 `<action>` / `</action>`
|
||||
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
|
||||
*
|
||||
* `ParsedBubble` 结构(每条气泡渲染一个):
|
||||
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
|
||||
* - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉行内标签:
|
||||
* 白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块;
|
||||
* 整段只有标记没有字、或行内标签**结构不平衡**时回落/降级为字面原文;
|
||||
* - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画);
|
||||
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
|
||||
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
|
||||
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
|
||||
@@ -34,9 +67,28 @@ 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) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
|
||||
* - 落单标签的剥离**不需要**额外的扣留规则,这是「剥标签与扣留共用同一组标签正则」的直接结果:
|
||||
* 扣留点取「第一个没闭合的开标签」,而落在前缀里的每个 `<p>` / `<action>` 开标签必定在
|
||||
* **同一个前缀内**配到闭合标签(否则它就是那个没闭合的,扣留点会落在它前面)。所以
|
||||
* 「这个标签是不是落单的」在它被产出的那一刻就已经定死,中间态剥掉、收口时也不用再改口。
|
||||
* - 反过来放宽扣留是不行的:把重复的 `<p><p>` 当「外层冗余」让前缀继续推进,`<p>甲<p>乙`
|
||||
* 就会在中间态把 `<p>甲` 当字面文本吐上屏,等 `</p>` 到了又变成 `甲乙`——字会跳。
|
||||
* 剥标签与扣留的标签词表因此必须逐字一致(共用 OPEN_P_TAG_RE / CLOSE_P_TAG_RE /
|
||||
* OPEN_ROLE_TAG_RE / CLOSE_ROLE_TAG_RE,无大小写、无词表扩展),两边一旦分叉,
|
||||
* 就会出现「中间态当正文、收口时又剥掉」的跳动。
|
||||
* - 标签残缺判断只影响「何时产出」,不影响最终结果;扣留期间不会抛异常。
|
||||
* - 本类无副作用、非线程安全,调用方保证单线程喂入即可。
|
||||
*/
|
||||
@@ -61,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()
|
||||
@@ -91,12 +153,43 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
|
||||
/** 无关键词的全角星块超过此长度即视为普通文本(对齐 Web `STAR_SHORT_RUNES`)。 */
|
||||
private const val FULL_STAR_MAX_LEN = 40
|
||||
|
||||
private val TAG_NAMES = listOf("action", "narration", "p")
|
||||
/**
|
||||
* 段落/旁白标签名(**只有小写**,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
|
||||
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面那组同词表的正则,两边必须同源。
|
||||
*
|
||||
* `<br>` 不在这个名单里:它是分隔符、且**大小写不敏感**(见 [BR_TAG_RE]),判据单独走
|
||||
* [couldBeLineBreakNamePrefix],否则 `<BR` 会被漏扣、中间态把 `<BR` 当正文吐上屏。
|
||||
*/
|
||||
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()
|
||||
@@ -144,29 +237,339 @@ 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()) {
|
||||
appendAnnotations(raw, out, splitBlankLines = true)
|
||||
// 一条完整块都没有:`<p>` 及其后内容按既有契约整段回落字面文本,`<action>` 这类旁白标签
|
||||
// 落单时同理(`MessageEditTextTest.没闭合的 action 同样原样留着`)。此处只剥掉连「被截断的
|
||||
// 结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。判据见 [stripLooseTags]。
|
||||
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(lead, out, splitBlankLines = false)
|
||||
if (lead.isNotEmpty()) {
|
||||
appendRegion(lead, out, markupMode = true, stripStrayRoleTags = true, splitBlankLines = false)
|
||||
}
|
||||
out.add(Tok.Break)
|
||||
val inner = m.groupValues[2]
|
||||
if (inner.isNotEmpty()) appendAnnotations(inner, out, splitBlankLines = false)
|
||||
if (inner.isNotEmpty()) {
|
||||
// 块内只剥 `<p>` 族:`<action>` / `</action>` 落单时保留字面文本,是既有契约
|
||||
// (unclosedActionFallsBackToText / strayClosingTagStaysText 钉住的行为)。
|
||||
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(trail, out, splitBlankLines = false)
|
||||
if (trail.isNotEmpty()) {
|
||||
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)
|
||||
|
||||
/**
|
||||
* 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。
|
||||
*
|
||||
* [markupMode] = 这条消息里**已有完整 `<p>…</p>` 块**,段落结构已被完整块定死,于是块内/块外
|
||||
* 多出来的每一个 `<p>` / `</p>` 都是冗余噪声,一律剥掉。
|
||||
* 没有完整块时(`parse` 会整段回落字面文本,见 `unclosedParagraphStaysLiteralText`;编辑框也靠
|
||||
* 这条保证不静默改字,见 `MessageEditTextTest.没闭合的 p 不抛异常`)只再剥两类不可能是正文的东西:
|
||||
* 落单的 `</p>`,以及后面什么都没有的**空**开标签——它们连「被截断的段落」都算不上。
|
||||
*
|
||||
* [stripStrayRoleTags] 只对**块外**文本区为 true:那里的 `<action>` / `</action>` 若没被 [ROLE_RE]
|
||||
* 认领(即没配成对),就是落单标签,剥掉。已配对的必须留给 [ROLE_RE] 解析成 Action/Narration,
|
||||
* 提前剥掉等于把旁白降级为正文(`textOutsideParagraphsIsPreserved` 钉住的行为)。
|
||||
*/
|
||||
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
|
||||
while (i < text.length) {
|
||||
val lt = text.indexOf('<', i)
|
||||
if (lt < 0) break
|
||||
val gt = text.indexOf('>', lt)
|
||||
if (gt < 0) break
|
||||
val tag = text.substring(lt, gt + 1)
|
||||
val drop = when {
|
||||
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
|
||||
else -> OPEN_ROLE_TAG_RE.matches(tag) || CLOSE_ROLE_TAG_RE.matches(tag)
|
||||
}
|
||||
if (drop) {
|
||||
out.append(text, last, lt)
|
||||
last = gt + 1
|
||||
i = last
|
||||
} else {
|
||||
i = lt + 1
|
||||
}
|
||||
}
|
||||
if (last == 0) return text
|
||||
out.append(text, last, text.length)
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* [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
|
||||
}
|
||||
}
|
||||
|
||||
/** 把这次走链途经的位置一次落表:它们与起点走的是同一条链,答案必然相同。 */
|
||||
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)) return gt + 1
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UNKNOWN: Byte = 0
|
||||
const val NOISE_ONLY: Byte = 1
|
||||
const val HAS_CONTENT: Byte = 2
|
||||
}
|
||||
}
|
||||
|
||||
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
|
||||
private fun appendAnnotations(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
|
||||
var last = 0
|
||||
@@ -307,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,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -354,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
|
||||
}
|
||||
@@ -3,48 +3,192 @@ package top.yeij.cyrene.domain.share
|
||||
/**
|
||||
* 从别的 App 分享进来、已经可以落地的内容。
|
||||
*
|
||||
* [images] 是**本端自己落盘后**的 `file://` URI:外部 `content://` 的读授权只在这个 intent
|
||||
* 的接收方(Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以图片必须在收到的那一刻
|
||||
* 就拷进 app 的 cache 目录(见 `app/ShareIntake.kt`)。
|
||||
* [images] / [documents] 里的 URI 在**提取阶段**还是对方给的原始 URI;由 `app/ShareIntake.kt`
|
||||
* 拷进缓存后换成**本端自己**的 `file://`:外部 `content://` 的读授权只在这个 intent 的接收方
|
||||
* (Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以必须在收到的那一刻就落盘。
|
||||
*
|
||||
* 两个丢件计数分开记,是因为它们该说的话不一样:截断是「本端的规则」,读不到是「对方给的
|
||||
* 东西用不了」。分享进来的内容**不许静默少发**——少发的部分要在界面上如实讲清楚。
|
||||
* 丢件计数按**两条路分开记**(图片 / 文档),因为「本端的接收上限」与「对方给的东西读不到」
|
||||
* 是两句不同的话,而图片与文档的上限也不是同一个数。分享进来的内容**不许静默少发**。
|
||||
*
|
||||
* 还有第三件要说的:[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(),
|
||||
/** 超过 [ShareRules.MAX_IMAGES] 而未被接收的条数。 */
|
||||
/**
|
||||
* 非图片的分享文件。MIME 以 `image/` 开头者走 [images],其余一律走这一条
|
||||
* (判定见 [classifySharedStream])——这正是修复「把一份 `.md` 当图片去解码、
|
||||
* 最后报一句『图片读不到』」的地方。
|
||||
*/
|
||||
val documents: List<SharedDocument> = emptyList(),
|
||||
/** 超过 [ShareRules.MAX_IMAGES] 而未被接收的图片条数。 */
|
||||
val truncatedImages: Int = 0,
|
||||
/** 已接收但**拷贝失败**(读不到 / 写不进 cache)的条数。 */
|
||||
/** 已接收但**拷贝失败**(读不到 / 写不进 cache)的图片条数。 */
|
||||
val failedImages: Int = 0,
|
||||
/** 超过 [ShareRules.MAX_DOCUMENTS] 而未被接收的文件条数。 */
|
||||
val truncatedDocuments: Int = 0,
|
||||
/** 已接收但**拷贝失败**的文件条数。 */
|
||||
val failedDocuments: Int = 0,
|
||||
/**
|
||||
* 这次分享**带着内容迹象**(三个文本 extra 里躺着值、或 `EXTRA_STREAM` 有值、或 `ClipData`
|
||||
* 有条目),却一条可用内容都没提取出来。
|
||||
*
|
||||
* 与 [failedImages] / [failedDocuments] **不是**同一件事:那两个说的是「本端已经认下来的地址
|
||||
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连可用的内容都没认下来」(多为发送方把
|
||||
* `EXTRA_STREAM` 塞成了本端认不出的形态、用本端不读的 scheme,或把正文放在本端认不出的地方)。
|
||||
* 它是**唯一**一种既没有内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是
|
||||
* 「用户点了分享、界面毫无反应」。
|
||||
*/
|
||||
val contentUnreadable: Boolean = false,
|
||||
) {
|
||||
/** 没能落地的总条数。 */
|
||||
val droppedImages: Int get() = truncatedImages + failedImages
|
||||
|
||||
/** 没有任何可发送的内容(此时只剩下 [droppedImages] 需要告知)。 */
|
||||
val isEmpty: Boolean get() = text.isBlank() && images.isEmpty()
|
||||
/** 没能落地的文件总条数。 */
|
||||
val droppedDocuments: Int get() = truncatedDocuments + failedDocuments
|
||||
|
||||
/** 没有任何可发送的内容(此时只剩下丢件计数需要告知)。 */
|
||||
val isEmpty: Boolean get() = text.isBlank() && images.isEmpty() && documents.isEmpty()
|
||||
|
||||
/**
|
||||
* 这条分享值不值得继续走:有可落地的内容,或有要对用户说清的事(丢件计数 / 读不进来)。
|
||||
*
|
||||
* 判据只此一处:`ShareIntake.accept` 用它决定要不要往下接,`shareNoticeFor` 用它守卫「什么都没有
|
||||
* 的分享一个字都不说」那条边界——两处必须同一条,否则会出现「接了却不说」或「说了却没接」。
|
||||
* 整条分享里什么都没有(三个文本 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,
|
||||
}
|
||||
|
||||
/**
|
||||
* 一份分享进来的**非图片**文件。
|
||||
*
|
||||
* [uri] 是拷进 cache 后的本端 `file://` 地址;[displayName] 只用来给上传部件命名(服务端只看
|
||||
* 扩展名)与给用户看,[sizeBytes] 用于发送前的本地体积预检(拿不到时是 -1,不猜)。
|
||||
*/
|
||||
data class SharedDocument(
|
||||
val uri: String,
|
||||
val displayName: String = "",
|
||||
val sizeBytes: Long = -1L,
|
||||
)
|
||||
|
||||
/** 一条分享进来的 URI 该走哪条路。 */
|
||||
enum class SharedStreamKind { Image, Document }
|
||||
|
||||
/**
|
||||
* 按 MIME 分流(纯函数,可单测)。
|
||||
*
|
||||
* **以 `image/` 开头才当图片**,其余(含 `null`、`text/plain`、`application/pdf`……)一律走文档路:
|
||||
* 此前把所有 `EXTRA_STREAM` 都当图片拷、当图片解码,于是一份 `.md` 只有两种结局——
|
||||
* 「图片处理失败」或者被悄悄当成一张读不出来的图。文档路至少会把字节原样交给服务端,
|
||||
* 由**字节嗅探**裁决(白名单内的放行,其余用 `415` 的文案如实说)。
|
||||
*
|
||||
* `null`(provider 没报类型)也走文档路:本端**不猜**类型,猜错等于替用户把一个合规文件
|
||||
* 变成一次失败。代价是「图片但 provider 没报 MIME」会落到文档路并被服务端拒——这是刻意的
|
||||
* 取舍,见 `app/ShareIntake.kt` 的 KDoc。
|
||||
*/
|
||||
fun classifySharedStream(mime: String?): SharedStreamKind {
|
||||
val base = mime?.substringBefore(';')?.trim()?.lowercase()
|
||||
return if (base != null && base.startsWith("image/")) SharedStreamKind.Image else SharedStreamKind.Document
|
||||
}
|
||||
|
||||
/**
|
||||
* 分享入口的规则(纯函数,与 Android 无关,可单测;Intent 的读取在 `app/ShareIntake.kt`)。
|
||||
*
|
||||
* ## 为什么只认这两个 action、两类 MIME
|
||||
* Manifest 只声明 `text/plain` 与 `image/*`:声明的每一类都必须是**本端真能落地**的东西。
|
||||
* 声明 `*/*` 会让「分享 PDF / 视频 / 压缩包给昔涟」出现在系统分享菜单里,而对话路径上今天
|
||||
* 根本没有「文件」这个表示(协同文档 `02/F1`:表示 / 存储 / 摄取三缺)——那会造出一个
|
||||
* **点进去必然失败**的入口。收窄之后非图片文件干脆不进分享菜单,用户看不到一个假的选项。
|
||||
* ## 为什么只认这两个 action、Manifest 里只声明两类 MIME
|
||||
* Manifest 只声明 `text/plain` 与图片那一类(`image/` 前缀):声明的每一类都必须是**本端真能
|
||||
* 落地**的东西。后端**明令拒绝** `text/html` / SVG / XML / 压缩包 / 脚本,声明全通配 MIME
|
||||
* 或文本通配 MIME 会造出一堆「点进去必然失败」的入口(`02/F1` 那条的三缺早已补上,但类型
|
||||
* 白名单本身就是 deny-by-default 的 22 个扩展名)。`text/plain` 已经能接住 `.txt` / `.md` /
|
||||
* `.log` 这类文件管理器会报 `text/plain` 的文件;`.md` 若被报成 `text/markdown`,那它**不进**
|
||||
* 分享菜单——少一个入口,好过一个必然失败的入口。(**Manifest 不动**是硬约束。)
|
||||
*
|
||||
* ## 两个 action 与两条路
|
||||
* [ACTION_SEND] 收文本 + 一个 URI(图片或文件);[ACTION_SEND_MULTIPLE] 本端只声明了图片那一类,
|
||||
* 所以多选一条实际只可能是图片。URI 走哪条路由 [classifySharedStream] 按 MIME 判。
|
||||
*
|
||||
* ## 字符串形态的 `EXTRA_STREAM`(本模块补上的兼容读取)
|
||||
* 真实世界里 `EXTRA_STREAM` 有三种形态:规范推荐的 `Uri`(多选时 `ArrayList<Uri>`)、`String[]`、
|
||||
* `ArrayList<String>`。后两种本模块此前**完全认不出**:`getParcelableExtra` 遇到 `String[]` 只往
|
||||
* logcat 打一句 `ClassCastException`、然后回 `null`,`getParcelableArrayListExtra` 同理,于是整次
|
||||
* 分享**静默落空**——用户点了分享,后端零请求、库里零新增、界面连一句话都没有。历史遗留的发送方与
|
||||
* 跨平台壳应用确实在用这两种形态,所以一并收下。形态分派在 `app/ShareIntake.kt` 的
|
||||
* `shareStreamExtrasOf`(那一步要读 `Intent` 的原始值),这里只管「拿到一串字符串之后怎么用」。
|
||||
*
|
||||
* ⚠️ **字符串形态只认 `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 {
|
||||
|
||||
/** `Intent.ACTION_SEND`:一条文本,或一张图片(可能还带一段说明文字)。 */
|
||||
/** `Intent.ACTION_SEND`:一条文本,或一张图片 / 一个文件(可能还带一段说明文字)。 */
|
||||
const val ACTION_SEND = "android.intent.action.SEND"
|
||||
|
||||
/** `Intent.ACTION_SEND_MULTIPLE`:多条;本端只接图片。 */
|
||||
/** `Intent.ACTION_SEND_MULTIPLE`:多条;本端 Manifest 只声明了图片那一类。 */
|
||||
const val ACTION_SEND_MULTIPLE = "android.intent.action.SEND_MULTIPLE"
|
||||
|
||||
/**
|
||||
@@ -56,50 +200,229 @@ object ShareRules {
|
||||
*/
|
||||
const val MAX_IMAGES = 4
|
||||
|
||||
/**
|
||||
* 一次分享最多接收几个非图片文件。
|
||||
*
|
||||
* 取服务端的一个回合上限(契约 v0.59 §1.2:`documents` **≤5 条**)——附件是**挂在同一条
|
||||
* 消息上**的,所以这里不能像图片那样「多出来的另起几条消息」:超过就是服务端
|
||||
* `400 too_many_documents`。超出的部分同样如实告知,绝不静默裁。
|
||||
*/
|
||||
const val MAX_DOCUMENTS = 5
|
||||
|
||||
/**
|
||||
* 图片超限的那一句(**纯函数**):**分享入口与图片选择入口共用同一句**,所以它只有一个出处。
|
||||
*
|
||||
* 两个入口的上限本来就是同一个数([MAX_IMAGES])⇒ 告知也必须是同一句:各写一句,迟早会出现
|
||||
* 「分享说只发前 4 张、选图说只发前 4 张(措辞不同)」这种同一件事的两种说法。
|
||||
*
|
||||
* ## 措辞**入口中性**(主人 2026-10-06 拍板)
|
||||
* W42 把图片按钮改成多选之后,这一句不止在分享那条路上出现——图片入口里根本没有「分享」
|
||||
* 这件事。原句以「**分享的**图片」开头,那是一句只对其中一条路成立的限定(在图片入口里读着
|
||||
* 像在说别人的事),所以句子不再指认动作的来处:只说三件两条路都成立的事实——**有上限**
|
||||
* ([MAX_IMAGES] 张)、**超出的没发**、**发出去的是前 N 张**。也刻意不写「失败」「错误」:
|
||||
* 被上限截掉不是一次错误,是上限本身。
|
||||
*
|
||||
* @param kept 这次真正发出去的张数(`SharedContent.images` 的长度 / `ImageSendPlan.imagesToSend` 的长度)
|
||||
* @param dropped 被上限截掉的张数;0 ⇒ `null`(一张没少就没什么要说的,说话才是噪声)
|
||||
*/
|
||||
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_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有
|
||||
* App(尤其旧版本)只填 `ClipData` 而不填 `EXTRA_STREAM`,只认前者会让分享静默落空。
|
||||
* 两者都有时**只取前者**,不会把同一张图数两遍。
|
||||
* ## 正文的回落链:`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`(只在前两者一条都没认下时兜底)。同一个地址串被两处各给一次时**只留先到的那一条**:
|
||||
* 先 trim、去空、去重,再按 MIME 分流、再各自套上限——重复项既不该把真图挤到上限之外,也不该
|
||||
* 被算成「截掉了 N 条」。**先来的先算**这条在字符串形态上尤其要紧:发送方给的顺序就是它给的顺序。
|
||||
*
|
||||
* `ClipData` 只在 `EXTRA_STREAM` 一条都没认下时才用(既有口径):两者都有时只取前者,不会把同一张
|
||||
* 图数两遍。
|
||||
*
|
||||
* @param action `Intent.action`;只认 [ACTION_SEND] / [ACTION_SEND_MULTIPLE]
|
||||
* @param text `EXTRA_TEXT`——按 `CharSequence` 收,因为它常常是 `Spanned`(带样式的
|
||||
* HTML 富文本),读成 `String` 会直接拿到 `null`
|
||||
* @param stream `EXTRA_STREAM`([ACTION_SEND] 的单条 URI 字符串)
|
||||
* @param streams `EXTRA_STREAM`([ACTION_SEND_MULTIPLE] 的 URI 列表)
|
||||
* @param clipUris `ClipData` 各条目的 URI;文本类条目没有 URI,会被自动忽略
|
||||
* 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[]` /
|
||||
* `ArrayList<String>` 摊平后的值,形态分派在 `app/ShareIntake.kt`)。这一档每条都要过
|
||||
* [stringStreamUri] 的白名单,被拒的**逐条跳过**;调用方给的是原样值(还没过滤),
|
||||
* 所以「有值但一条都不能用」这件事这里看得见——它就是 [SharedContent.contentUnreadable]
|
||||
* 要说的那种情形。
|
||||
* @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`)。本函数是
|
||||
* **纯函数**,所以类型判定由调用方注入;默认「拿不到类型」⇒ 按文档路走
|
||||
* (见 [classifySharedStream],默认值就是「不猜」)。
|
||||
*/
|
||||
fun extract(
|
||||
action: String?,
|
||||
text: CharSequence? = null,
|
||||
subject: CharSequence? = null,
|
||||
title: CharSequence? = null,
|
||||
stream: String? = null,
|
||||
streams: List<String>? = null,
|
||||
clipUris: List<String> = emptyList(),
|
||||
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()
|
||||
|
||||
val primary = when (action) {
|
||||
ACTION_SEND -> listOfNotNull(stream)
|
||||
else -> streams.orEmpty()
|
||||
// 回落链(见 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 }
|
||||
}
|
||||
// 空串条目(有的 App 会塞占位)与重复条目都要去掉:不然「4 张图」里可能只剩 2 张真图,
|
||||
// 而截断计数还会把占位算成一张。
|
||||
val unique = primary.ifEmpty { clipUris }
|
||||
val body = picked?.second.orEmpty()
|
||||
val textSource = picked?.first ?: ShareTextSource.Text
|
||||
|
||||
// 两个字段一并收:单值 / 列表是历史形态(当年靠 getParcelableExtra 与
|
||||
// getParcelableArrayListExtra 两个读法分开取,才不得不按 action 二选一),
|
||||
// 本端现在只关心「有哪些地址」,重复的由下面的 distinct 收掉。
|
||||
val uriForms = listOfNotNull(stream) + streams.orEmpty()
|
||||
val primary = (uriForms + streamStrings.mapNotNull(::stringStreamUri))
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.distinct()
|
||||
// 空串条目(有的 App 会塞占位)与重复条目都要去掉:不然「4 张图」里可能只剩 2 张真图,
|
||||
// 而截断计数还会把占位算成一张。
|
||||
val unique = primary.ifEmpty {
|
||||
clipUris.filterNotNull().map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
}
|
||||
|
||||
val images = mutableListOf<String>()
|
||||
val documents = mutableListOf<String>()
|
||||
unique.forEach { uri ->
|
||||
when (classifySharedStream(mimeOf(uri))) {
|
||||
SharedStreamKind.Image -> images += uri
|
||||
SharedStreamKind.Document -> documents += uri
|
||||
}
|
||||
}
|
||||
|
||||
// 「带着内容迹象」的判据取的是**原料**(三个文本 extra 里躺着的原始值 / EXTRA_STREAM 里躺着的
|
||||
// 原始值 / ClipData 的条目数),不是「认下来的内容」——「有值但一条都认不出」正是要说的那种
|
||||
// 情形,用后者判反倒会把它当成「什么都没分享」而闭嘴。
|
||||
val hadStreamValue = streamPresent ||
|
||||
!stream.isNullOrBlank() ||
|
||||
streams.orEmpty().any(String::isNotBlank) ||
|
||||
streamStrings.any(String::isNotBlank)
|
||||
|
||||
return SharedContent(
|
||||
text = body,
|
||||
images = unique.take(MAX_IMAGES),
|
||||
truncatedImages = (unique.size - MAX_IMAGES).coerceAtLeast(0),
|
||||
textSource = textSource,
|
||||
images = images.take(MAX_IMAGES),
|
||||
// 提取阶段还不知道显示名与大小(要 ContentResolver),先放原样 URI;
|
||||
// ShareIntake 拷进 cache 后再换成 file:// + 显示名 + 真实字节数。
|
||||
documents = documents.take(MAX_DOCUMENTS).map { SharedDocument(uri = it) },
|
||||
truncatedImages = (images.size - MAX_IMAGES).coerceAtLeast(0),
|
||||
truncatedDocuments = (documents.size - MAX_DOCUMENTS).coerceAtLeast(0),
|
||||
contentUnreadable = unique.isEmpty() && body.isEmpty() &&
|
||||
(hadStreamValue || textPresent || clipUris.isNotEmpty()),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 字符串形态的一条候选地址 ⇒ 可用的地址(trim 之后),不可用回 `null`。
|
||||
*
|
||||
* ## 为什么字符串形态只认 `content://`(安全纪律)
|
||||
* 字符串形态是本端**新开**的一条读入口:送进来的只是一段文本,没有任何框架级的担保(`Uri` /
|
||||
* `ArrayList<Uri>` 那两个形态至少是对方用 URI **对象**写进去的,而 `EXTRA_STREAM` 里的字符串
|
||||
* 往往是从别处转发来的——网页给的、壳应用拼的)。所以这条入口开得窄:
|
||||
*
|
||||
* - `content://` ⇒ 收下。这是 AOSP 推荐的形态,读权限随这次分享由框架授予。
|
||||
* - `file://` ⇒ **拒**。那等于让发送方替本端指定一个本地路径、由本端拿**自己的**权限去读它
|
||||
* (本应用私有目录也在其中),而分享的语义是「用户选中的东西」,不是「别人给的任意路径」。
|
||||
* 何况 Android 13 起跨应用读外部存储的 `file://` 基本已不通,收下来多半也只是变成一次失败。
|
||||
* - `http(s)://` ⇒ **拒**。那是网络地址不是本地字节源:收下它等于让第三方的一行文本指定一个 URL、
|
||||
* 由本应用代它去取,而那条取法在本端没有任何既有的授权 / 体积 / 超时约束。
|
||||
* - 其余(`javascript:` / `intent:` / 自定义 scheme / 没有 scheme 的裸路径 / 空串)⇒ **拒**。
|
||||
*
|
||||
* 被拒的条目由调用方逐条跳过(见 [extract]):一次分享里有几条坏的,不该把整次分享废掉;
|
||||
* 一条可用地址都没剩下时由 [SharedContent.contentUnreadable] 如实告知。
|
||||
*
|
||||
* 判 scheme 用的是 RFC 3986 的形态(首字符字母,其后字母 / 数字 / `+` `-` `.`),与 `Uri.parse`
|
||||
* 认 scheme 的口径一致;本函数是纯的(`domain/share` 不碰 Android 类型)。
|
||||
*/
|
||||
fun stringStreamUri(raw: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
val scheme = schemeOf(trimmed) ?: return null
|
||||
return trimmed.takeIf { scheme.equals(CONTENT_SCHEME, ignoreCase = true) }
|
||||
}
|
||||
|
||||
/** 本端唯一收下的 scheme(理由见 [stringStreamUri])。 */
|
||||
private const val CONTENT_SCHEME = "content"
|
||||
|
||||
/** 一条地址的 scheme(按 RFC 3986 的形态判,大小写不敏感地在调用方比);没有 scheme 回 `null`。 */
|
||||
private fun schemeOf(raw: String): String? {
|
||||
val colon = raw.indexOf(':')
|
||||
if (colon <= 0) return null
|
||||
val head = raw.substring(0, colon)
|
||||
val first = head[0]
|
||||
if (first !in 'a'..'z' && first !in 'A'..'Z') return null
|
||||
val usable = head.drop(1).all {
|
||||
it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '+' || it == '-' || it == '.'
|
||||
}
|
||||
return head.takeIf { usable }
|
||||
}
|
||||
|
||||
/**
|
||||
* 落盘文件名:**只由本端生成**的 [token] 与序号 [index] 拼成,扩展名取自 MIME 白名单
|
||||
* (不在表里就回落 `.img`——真正的类型由字节嗅探裁决,不靠扩展名)。
|
||||
@@ -108,13 +431,31 @@ object ShareRules {
|
||||
* 口子接到自己的 cache 目录上。这个函数是纯的,所以它连 [token] 都不信——非法字符一律折成
|
||||
* `_`,`. .` 因此拼不出 `..`,`/` 也活不下来。
|
||||
*/
|
||||
fun cacheFileName(token: String, index: Int, mime: String?): String {
|
||||
val safe = token.map { c ->
|
||||
if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '-' || c == '_') c else '_'
|
||||
}.joinToString("")
|
||||
return "share-${safe.ifEmpty { "x" }}-$index.${extensionFor(mime)}"
|
||||
fun cacheFileName(token: String, index: Int, mime: String?): String =
|
||||
"share-${safeToken(token)}-$index.${extensionFor(mime)}"
|
||||
|
||||
/**
|
||||
* 非图片分享文件的缓存名。
|
||||
*
|
||||
* 扩展名**照抄对方给的名字**(服务端按扩展名判白名单,缓存件本身只是本端的临时载体,
|
||||
* 但名字对得上排查时少一次误解),并且同样只由本端 token 与序号构成路径——
|
||||
* 对方名字里的 `/` 与 `..` 活不下来(见 [safeExtension])。
|
||||
*/
|
||||
fun documentCacheFileName(token: String, index: Int, originalName: String): String =
|
||||
"share-${safeToken(token)}-$index.${safeExtension(originalName)}"
|
||||
|
||||
/** 非白名单 / 空 / 含非法字符的扩展名一律回落 `txt`(**不猜**类型)。 */
|
||||
private fun safeExtension(originalName: String): String {
|
||||
val extension = originalName.substringAfterLast('.', "").lowercase()
|
||||
val usable = extension.length in 1..8 && extension.all { it.isLetterOrDigit() }
|
||||
return if (usable) extension else "txt"
|
||||
}
|
||||
|
||||
private fun safeToken(token: String): String =
|
||||
token.map { c ->
|
||||
if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '-' || c == '_') c else '_'
|
||||
}.joinToString("").ifEmpty { "x" }
|
||||
|
||||
/** MIME → 扩展名;未知 / 缺失回落 `img`。 */
|
||||
private fun extensionFor(mime: String?): String =
|
||||
when (mime?.substringBefore(';')?.trim()?.lowercase()) {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.domain.admin.rfc3339EpochSecond
|
||||
|
||||
/**
|
||||
* 可观测面的**合并**与**本地过滤**(纯函数,契约 §2「可观测性契约」+ §3.12)。
|
||||
*
|
||||
* 两个来源各自负责一半,谁都不替代谁:
|
||||
* - **REST 引导读取**(进页面 / 手动刷新 / 断线重连)给一份**全量**快照(最新在前);
|
||||
* - **推送帧**是**增量**:`observability_trace` 只带订阅之后新完成的 span,
|
||||
* `observability_log` 只带订阅之后新增的日志(订阅时以「当前最新」为锚,服务端**不会**
|
||||
* 把环形缓冲里的历史灌成巨型帧);`observability_metrics` 是**整份快照**,见
|
||||
* `AdminViewModel`(它是替换而不是合并,所以不在这里)。
|
||||
*
|
||||
* 两份东西的合并口径**刻意不一样**:
|
||||
* - **日志**按 `time` 排成**时间倒序**(见 [normalizeLogEntries])——列表的整体顺序是客户端
|
||||
* 自己拼出来的,契约只对**单份来源**保证「最新在前」,拼接口上还有一个精度差(服务端推送锚点
|
||||
* 用的是纳秒内部时间,下发的 `time` 却是秒精度),所以这里不去**推**顺序,而是自己**定义**它;
|
||||
* - **trace** 保持「新帧接在前面」——span 有服务端写入序号,帧本来就是按序来的,
|
||||
* 而它的 `startedAt` 是纳秒精度、还可能是不同偏移,重排反而会把正确的顺序排乱。
|
||||
*/
|
||||
|
||||
/** trace 保留窗口,与 REST 引导读取的 `limit`(`AdminRepository.DEFAULT_TRACE_LIMIT`)一致。 */
|
||||
internal const val TRACE_WINDOW = 100
|
||||
|
||||
/**
|
||||
* 日志保留窗口:与 REST 引导读取的 `limit`(`AdminRepository.DEFAULT_LOG_LIMIT`)以及契约
|
||||
* §3.12 的单帧上限一致(更旧的条目本就落在保留窗口之外)。三个数必须同源,否则会出现
|
||||
* 「服务端给了 200 条、客户端只留 100 条」这种悄悄丢数据的偏差。
|
||||
*/
|
||||
internal const val LOG_WINDOW = 200
|
||||
|
||||
/**
|
||||
* 合并 trace 帧:新 span 在前,按 `id` 去重(同一条 span 不会因为「帧与 REST 竞态」出现两次),
|
||||
* 保留最近 [window] 条。[incoming] 为空时**原样返回** [existing](空增量不改状态,
|
||||
* 也让 REST 那份快照保持原样)。
|
||||
*/
|
||||
internal fun mergeTraceSpans(
|
||||
existing: List<AdminTraceSpanDto>,
|
||||
incoming: List<AdminTraceSpanDto>,
|
||||
window: Int = TRACE_WINDOW,
|
||||
): List<AdminTraceSpanDto> = mergeNewestFirst(existing, incoming, window) { it.id }
|
||||
|
||||
/**
|
||||
* 归一化一份日志快照(REST 引导读取,或已经合并过的列表):**去重 → 按时间倒序 → 截到窗口**。
|
||||
* 这三步的**顺序不能换**:窗口是位置截断,先截再排就会让一条时间上更旧的条目挤掉更新的那条。
|
||||
*
|
||||
* 去重键取整条内容([logEntryKey]),是因为日志**没有 id**;重复时保留**先出现**的那一份,
|
||||
* 所以 [mergeLogEntries] 以「新到的在前」拼输入,帧里的那份会留在原位。
|
||||
*/
|
||||
internal fun normalizeLogEntries(
|
||||
entries: List<AdminLogEntryDto>,
|
||||
window: Int = LOG_WINDOW,
|
||||
): List<AdminLogEntryDto> = entries
|
||||
.distinctBy(::logEntryKey)
|
||||
.sortedWith(NEWEST_LOG_FIRST)
|
||||
.take(window)
|
||||
|
||||
/**
|
||||
* 合并日志帧:[incoming] 是**增量**(订阅之后新增的日志),[existing] 是已有基线
|
||||
* (REST 快照 + 之前几拍帧),结果满足 [normalizeLogEntries] 的同一套不变式。
|
||||
*
|
||||
* [incoming] 为空时**原样返回** [existing]:空增量不是「清空」,也让那次 REST 快照保持原样。
|
||||
*/
|
||||
internal fun mergeLogEntries(
|
||||
existing: List<AdminLogEntryDto>,
|
||||
incoming: List<AdminLogEntryDto>,
|
||||
window: Int = LOG_WINDOW,
|
||||
): List<AdminLogEntryDto> =
|
||||
if (incoming.isEmpty()) existing else normalizeLogEntries(incoming + existing, window)
|
||||
|
||||
/**
|
||||
* 日志的时间倒序:按 RFC3339 解析出的**秒**降序,用**稳定排序**(Kotlin 的 `sortedWith` 是稳定的)
|
||||
* 保住同秒条目的**到达顺序**——服务端下发的 `time` 只有秒精度,一秒内多条是常态,
|
||||
* 顺序信息只在拼接顺序里,不能被排序打乱。
|
||||
*
|
||||
* 时间判不出来的条目(正常线上不会出现:`time` 恒是 `Format(time.RFC3339)`)排在**最后**、
|
||||
* 但**不丢**:宁可让它落到列表尾部,也不因为「时间读不出来」把它从界面上抹掉。
|
||||
*/
|
||||
private val NEWEST_LOG_FIRST: Comparator<AdminLogEntryDto> =
|
||||
compareByDescending { rfc3339EpochSecond(it.time) ?: Long.MIN_VALUE }
|
||||
|
||||
/**
|
||||
* 日志条目的业务键;合并去重与列表 `key` 都用它,两处必须一致。
|
||||
*
|
||||
* 去重键取整条内容,是因为日志**没有 id**。代价如实说:同一秒内、来源与消息完全相同的两条
|
||||
* 日志会被收成一条——与 Web 面板(`observabilityStream.ts` 按业务键合并)同一口径。
|
||||
*/
|
||||
internal fun logEntryKey(entry: AdminLogEntryDto): String =
|
||||
"${entry.time}|${entry.level}|${entry.source}|${entry.msg}"
|
||||
|
||||
/**
|
||||
* 按**最低等级**过滤日志,纯客户端行为:服务端的 `?level=` 对推送不适用
|
||||
* (帧广播给所有 owner/admin 连接,订阅只决定推送循环跑不跑)。
|
||||
*
|
||||
* [minLevel] 为空 / 未知 → 不过滤。**等级未知的条目一律保留**:`level` 是开放集合,
|
||||
* 判不出高低的条目不能因为「可能低于阈值」被静默藏掉(与原样保留未知值的约定一致)。
|
||||
*/
|
||||
internal fun filterLogEntries(
|
||||
entries: List<AdminLogEntryDto>,
|
||||
minLevel: String?,
|
||||
): List<AdminLogEntryDto> {
|
||||
val threshold = levelRank(minLevel) ?: return entries
|
||||
return entries.filter { (levelRank(it.level) ?: Int.MAX_VALUE) >= threshold }
|
||||
}
|
||||
|
||||
/** 日志等级序:契约里的 `debug|info|warn|error` 由低到高;未知等级返回 null。 */
|
||||
private fun levelRank(level: String?): Int? = when (level?.trim()?.lowercase()) {
|
||||
"debug" -> 0
|
||||
"info" -> 1
|
||||
"warn" -> 2
|
||||
"error" -> 3
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* 「最新在前」列表的合并(**只给 trace 用**;日志走 [normalizeLogEntries],因为它还要按时间排序):
|
||||
* 先把 [incoming] 按原序接在前面(内部同样按 [key] 去重),再补上 [existing] 里没出现过的那些;
|
||||
* 最后截断到 [window]。
|
||||
*/
|
||||
private fun <T> mergeNewestFirst(
|
||||
existing: List<T>,
|
||||
incoming: List<T>,
|
||||
window: Int,
|
||||
key: (T) -> String,
|
||||
): List<T> {
|
||||
if (incoming.isEmpty()) return existing
|
||||
val seen = HashSet<String>(incoming.size + existing.size)
|
||||
val merged = ArrayList<T>(incoming.size + existing.size)
|
||||
for (item in incoming) if (seen.add(key(item))) merged += item
|
||||
for (item in existing) if (seen.add(key(item))) merged += item
|
||||
return if (merged.size <= window) merged else ArrayList(merged.subList(0, window))
|
||||
}
|
||||
@@ -13,13 +13,27 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.domain.admin.AdminFailure
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.PathGroup
|
||||
import top.yeij.cyrene.domain.admin.PathHit
|
||||
import top.yeij.cyrene.domain.admin.PathRow
|
||||
import top.yeij.cyrene.domain.admin.PathStats
|
||||
import top.yeij.cyrene.domain.admin.canReadObservabilityLogHistory
|
||||
import top.yeij.cyrene.domain.admin.formatCounts
|
||||
import top.yeij.cyrene.domain.admin.formatDouble
|
||||
import top.yeij.cyrene.domain.admin.formatNanos
|
||||
@@ -28,21 +42,52 @@ import top.yeij.cyrene.domain.admin.formatRfc3339
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
import top.yeij.cyrene.domain.admin.traceKindLabel
|
||||
import top.yeij.cyrene.domain.admin.traceStatusLabel
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.HorizontalDivider
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.TabRow
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 概览页:`metrics` 卡片 + 其下的 `trace` 列表(两次请求,见 api-client §2「可观测性契约」)。
|
||||
* 概览页:`metrics` 卡片 + 「按路径」分组列表 + 其下的 `trace` 列表 + 「最近日志」
|
||||
* (api-client §2「可观测性契约」与 §3.12「可观测实时推送」)。
|
||||
*
|
||||
* 三块都是**REST 打底、帧叠加**:首屏(以及每次刷新 / 断线重连)拉一次全量快照,
|
||||
* 之后 `observability_*` 三帧(仅 owner/admin 收得到)由 [AdminViewModel] 合进同一份状态。
|
||||
* 因此**每一块都按自己那次读取的结果决定怎么画**:区块里绝不出现「用别人的失败解释我」——
|
||||
* 三路的失败原因是分开的(见 `AdminOverviewData`)。
|
||||
*
|
||||
* 「最近日志」的历史来自 `GET /api/v1/admin/observability/logs`,那是 v0.40 的只读监控档,
|
||||
* **`viewer` 也读得到**;而增量帧只发 owner/admin。所以区块是否存在只看
|
||||
* [canReadObservabilityLogHistory](= 历史读不读得到),**不看帧有没有到过**——
|
||||
* `viewer` 的列表不增长,不是错误、也不是空态。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.overview.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Overview, role) }
|
||||
|
||||
// 折叠状态放在 LazyColumn 之外:行随滚动回收,状态不能挂在 item 里。
|
||||
// 只记**用户动过的组**,其余按分组默认档(见 PathStats.expanded),刷新不会把大组锁在打开状态。
|
||||
val toggledGroups = remember { mutableStateMapOf<String, Boolean>() }
|
||||
|
||||
// 日志等级选择(`null` = 全部)。**在本地过滤**:推送帧是广播给所有 owner/admin 连接的,
|
||||
// 服务端的 `?level=` 对帧不适用(契约 §3.12)。放这里而不是 LazyColumn 的 item 里,
|
||||
// 是为了让「读状态 → 重算列表」发生在正常的 compose 作用域里。
|
||||
var logLevel by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Overview.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
val data = state.data
|
||||
val failure = state.failure
|
||||
val visibleLogs = remember(data?.logs, logLevel) {
|
||||
filterLogEntries(data?.logs.orEmpty(), logLevel)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -53,7 +98,9 @@ fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -
|
||||
|
||||
data == null && state.loading -> AdminCenterHint("加载中…")
|
||||
|
||||
data == null || (data.metrics == null && data.trace.isEmpty()) ->
|
||||
// 三块都没有可画的东西才整页报错;只要有一路成功,就让它画出来、
|
||||
// 失败的区块各自说明自己(把一次成功藏在一句「加载失败」后面是另一种撒谎)。
|
||||
data == null || (data.metrics == null && data.trace.isEmpty() && data.logs == null) ->
|
||||
AdminFailureState(failure, reload)
|
||||
|
||||
else -> LazyColumn(
|
||||
@@ -68,15 +115,23 @@ fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -
|
||||
Spacer(Modifier.height(8.dp))
|
||||
AdminMetricCard(countRows(data.metrics))
|
||||
} else {
|
||||
AdminTextCard("指标不可用", failureMessage(failure))
|
||||
AdminTextCard("指标不可用", failureMessage(data.metricsFailure))
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "__paths__") {
|
||||
// metrics 缺席时这里整段不渲染:上面那张卡已经说明了「指标不可用」,
|
||||
// 再给一个空列表就是把失败画成「没有路径」。
|
||||
data.metrics?.let { metrics ->
|
||||
PathStatsSection(perPath = metrics.perPath, toggled = toggledGroups)
|
||||
}
|
||||
}
|
||||
item(key = "__trace_title__") { AdminSectionTitle("最近 trace") }
|
||||
when {
|
||||
// trace 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。
|
||||
failure != null -> item(key = "__trace_error__") {
|
||||
AdminTextCard("trace 不可用", failureMessage(failure))
|
||||
// 这里只看 **trace 自己**的失败:日志那边挂了不该让这块说「不可用」。
|
||||
data.traceFailure != null -> item(key = "__trace_error__") {
|
||||
AdminTextCard("trace 不可用", failureMessage(data.traceFailure))
|
||||
}
|
||||
|
||||
data.trace.isEmpty() -> item(key = "__trace_empty__") {
|
||||
@@ -87,6 +142,35 @@ fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -
|
||||
TraceSpanCard(span)
|
||||
}
|
||||
}
|
||||
// 日志:历史来自 REST 引导读取(viewer 也读得到),增量来自 observability_log 帧
|
||||
// (只发 owner/admin)。区块的存在只由「历史读不读得到」决定,帧有没有到过**不参与**
|
||||
// ——viewer 收不到增量,不该因此看到任何错误或空态(契约 §2.0 / §3.12)。
|
||||
if (canReadObservabilityLogHistory(role)) {
|
||||
item(key = "__logs_title__") { AdminSectionTitle("最近日志") }
|
||||
when {
|
||||
// 有基线(含空列表):这一块的内容就是它 + 之后的帧增量。
|
||||
data.logs != null -> if (data.logs.isEmpty()) {
|
||||
item(key = "__logs_empty__") { AdminInlineHint("暂无日志") }
|
||||
} else {
|
||||
item(key = "__logs_level__") { LogLevelTabs(logLevel) { logLevel = it } }
|
||||
if (visibleLogs.isEmpty()) {
|
||||
item(key = "__logs_filtered__") { AdminInlineHint("当前等级下没有日志") }
|
||||
} else {
|
||||
items(items = visibleLogs, key = { logEntryKey(it) }) { entry ->
|
||||
LogEntryCard(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 那次引导读取失败:说失败,绝不说「暂无日志」。
|
||||
data.logsFailure != null -> item(key = "__logs_error__") {
|
||||
AdminTextCard("日志不可用", failureMessage(data.logsFailure))
|
||||
}
|
||||
|
||||
// 读取还在飞(例如上一次「日志不可用」之后按了刷新):说加载中。
|
||||
else -> item(key = "__logs_loading__") { AdminInlineHint("日志加载中…") }
|
||||
}
|
||||
}
|
||||
item(key = "__bottom__") { Spacer(Modifier.height(32.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -103,12 +187,112 @@ private fun metricRows(metrics: AdminMetricsDto): List<Pair<String, String>> = l
|
||||
"子代理事件" to metrics.subagentEvents.toString(),
|
||||
)
|
||||
|
||||
/** 「按方法」「状态码」两类计数仍是一行文本:它们最多三五条,分组反而更碎。 */
|
||||
private fun countRows(metrics: AdminMetricsDto): List<Pair<String, String>> = listOf(
|
||||
"按方法" to formatCounts(metrics.perMethod).ifBlank { "-" },
|
||||
"按路径" to formatCounts(metrics.perPath).ifBlank { "-" },
|
||||
"状态码" to formatCounts(metrics.statusCounts).ifBlank { "-" },
|
||||
)
|
||||
|
||||
/**
|
||||
* 「按路径」区块:分组 + 可折叠(分组规则与排序全在 [PathStats],这里只负责画)。
|
||||
*
|
||||
* 首屏可读性靠两件事:**条数多的大组默认收起**(真实响应里 15 条 admin 路径收成一行组头)、
|
||||
* 小组默认展开(省一次点击);行里同时给「条数」与「次数」,因为「15 条路径」和「283 次命中」
|
||||
* 是两个不同的信息。
|
||||
*/
|
||||
@Composable
|
||||
private fun PathStatsSection(perPath: Map<String, Long>, toggled: MutableMap<String, Boolean>) {
|
||||
val groups = remember(perPath) { PathStats.groupPaths(perPath) }
|
||||
val pathCount = groups.sumOf { it.pathCount }
|
||||
val hits = groups.sumOf { it.hits }
|
||||
// 这一行每次重组都要重算(toggled 是快照状态,读它才能跟着点按更新);纯计算,成本可忽略。
|
||||
val rows = PathStats.rows(groups, toggled)
|
||||
|
||||
AdminSectionTitle("按路径($pathCount 条 · $hits 次)")
|
||||
if (rows.isEmpty()) {
|
||||
// 走到这里说明 metrics 请求**成功**了而 `perPath` 为空——成功后的空,不是失败。
|
||||
AdminInlineHint("暂无路径统计")
|
||||
return
|
||||
}
|
||||
PreferenceCard {
|
||||
Column {
|
||||
rows.forEachIndexed { index, row ->
|
||||
if (index > 0) HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
when (row) {
|
||||
is PathRow.Header -> PathGroupRow(
|
||||
group = row.group,
|
||||
expanded = PathStats.expanded(row.group, toggled),
|
||||
onToggle = { toggled[row.group.prefix] = !PathStats.expanded(row.group, toggled) },
|
||||
)
|
||||
|
||||
is PathRow.Leaf -> PathLeafRow(hit = row.hit, nested = row.nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 组头:前缀 + 「N 条 · M 次」+ 箭头(展开时转向下)。整行可点,是唯一的折叠入口。 */
|
||||
@Composable
|
||||
private fun PathGroupRow(group: PathGroup, expanded: Boolean, onToggle: () -> Unit) {
|
||||
BasicComponent(
|
||||
onClick = onToggle,
|
||||
endActions = {
|
||||
Text(
|
||||
text = "${group.pathCount} 条 · ${group.hits} 次",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
Icon(
|
||||
imageVector = MiuixIcons.ChevronForward,
|
||||
contentDescription = if (expanded) "收起" else "展开",
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.rotate(if (expanded) 90f else 0f),
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = group.prefix,
|
||||
fontSize = MiuixTheme.textStyles.main.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径行:左路径、右计数。
|
||||
*
|
||||
* 路径用**等宽字体**(路径是给人对比字符用的,等宽最好扫读),字号取 `footnote1` 与计数一致;
|
||||
* 过长时**中间省略**——尾部(资源名)才是区分同类路径的部分,尾部省略等于把两条路径画成一样。
|
||||
*/
|
||||
@Composable
|
||||
private fun PathLeafRow(hit: PathHit, nested: Boolean) {
|
||||
BasicComponent(
|
||||
endActions = {
|
||||
Text(
|
||||
text = hit.count.toString(),
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = hit.display,
|
||||
modifier = if (nested) Modifier.padding(start = 12.dp) else Modifier,
|
||||
fontSize = MiuixTheme.textStyles.footnote1.fontSize,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TraceSpanCard(span: AdminTraceSpanDto) {
|
||||
AdminTextCard(
|
||||
@@ -119,6 +303,54 @@ private fun TraceSpanCard(span: AdminTraceSpanDto) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志一行:消息是主体,来源 / 时间作副文本,等级作尾注。
|
||||
*
|
||||
* 等级只给 `error` 上错误色——与 trace 卡按 `status` 着色同一口径(Miuix 配色里没有「警告」档,
|
||||
* 不猜一个近似色)。消息**不截断**:这是诊断视图,被截掉的往往正是要看的那半句。
|
||||
*/
|
||||
@Composable
|
||||
private fun LogEntryCard(entry: AdminLogEntryDto) {
|
||||
AdminTextCard(
|
||||
title = entry.msg.ifBlank { "(无消息)" },
|
||||
body = logMeta(entry),
|
||||
trailing = entry.level.ifBlank { null },
|
||||
trailingColor = if (entry.level.equals("error", ignoreCase = true)) {
|
||||
MiuixTheme.colorScheme.error
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 副文本:来源 + 本地时间;两者都可能缺,缺了就不占位。 */
|
||||
private fun logMeta(entry: AdminLogEntryDto): String = listOfNotNull(
|
||||
entry.source.takeIf { it.isNotBlank() },
|
||||
formatRfc3339(entry.time),
|
||||
).joinToString(" · ")
|
||||
|
||||
/**
|
||||
* 日志等级过滤档(`null` = 全部)。最低等级语义与后端 `?level=` 一致(`warn` 含 `error`),
|
||||
* 只是过滤发生在客户端——推送帧是广播的,服务端没有按连接分级。
|
||||
*/
|
||||
private val LOG_LEVEL_TABS = listOf<Pair<String, String?>>(
|
||||
"全部" to null,
|
||||
"信息+" to "info",
|
||||
"警告+" to "warn",
|
||||
"错误" to "error",
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun LogLevelTabs(selected: String?, onSelect: (String?) -> Unit) {
|
||||
TabRow(
|
||||
tabs = LOG_LEVEL_TABS.map { it.first },
|
||||
// 未知取值回落第一档:列表与选中的档位由**同一份清单**驱动,不会出现「都不选中」。
|
||||
selectedTabIndex = LOG_LEVEL_TABS.indexOfFirst { it.second == selected }.coerceAtLeast(0),
|
||||
onTabSelected = { index -> onSelect(LOG_LEVEL_TABS[index].second) },
|
||||
modifier = Modifier.padding(horizontal = 20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun traceBody(span: AdminTraceSpanDto): String = buildList {
|
||||
add("${traceKindLabel(span.kind)} · ${formatNanos(span.durationNs)}")
|
||||
add("开始 ${formatRfc3339(span.startedAt) ?: orDash(span.startedAt)}")
|
||||
|
||||
@@ -7,17 +7,25 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
||||
import top.yeij.cyrene.data.ws.ChatSocket
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
import top.yeij.cyrene.domain.admin.AdminFailure
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.canAccess
|
||||
import top.yeij.cyrene.domain.admin.canReadObservabilityLogHistory
|
||||
import top.yeij.cyrene.domain.admin.canReceiveObservabilityFrames
|
||||
import top.yeij.cyrene.domain.admin.toAdminFailure
|
||||
|
||||
/**
|
||||
@@ -31,10 +39,26 @@ data class AdminUiState<T>(
|
||||
val failure: AdminFailure? = null,
|
||||
)
|
||||
|
||||
/** 概览页:metrics 与 trace 两次请求合成一份状态(`trace` 作为指标卡片下方列表)。 */
|
||||
/** 概览页:metrics / trace / 日志三路取数合成一份状态。 */
|
||||
data class AdminOverviewData(
|
||||
val metrics: AdminMetricsDto? = null,
|
||||
val trace: List<AdminTraceSpanDto> = emptyList(),
|
||||
/**
|
||||
* 「最近日志」:null = **还没有历史基线**(引导读取在飞,或那次读取失败)⇒ 界面整段不渲染;
|
||||
* 非 null(**含空列表**)= REST 读过一次且成功 ⇒ 渲染区块,空列表就是**确实没有日志**。
|
||||
*
|
||||
* 推送帧是**增量**(`observability_log` 以订阅那一刻的最新条目为锚,不回灌历史),
|
||||
* 所以帧**不**负责把它从 null 变成非 null:没有基线就没有可以叠加的东西。
|
||||
*/
|
||||
val logs: List<AdminLogEntryDto>? = null,
|
||||
/**
|
||||
* 三路各自失败的原因。**必须分开记**:三路各自成败是常态(例如 logs 缓冲未注入回 501,
|
||||
* 而 metrics / trace 正常),共用一个 `failure` 会让「日志挂了」显示成「trace 不可用」——
|
||||
* 那是把一次成功说成失败。
|
||||
*/
|
||||
val metricsFailure: AdminFailure? = null,
|
||||
val traceFailure: AdminFailure? = null,
|
||||
val logsFailure: AdminFailure? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -42,6 +66,28 @@ data class AdminOverviewData(
|
||||
*
|
||||
* [load] 先按 [AdminRole] 短路:角色不够的页**不发请求**,直接给
|
||||
* [AdminFailure.Forbidden],UI 因此能显示「无权限」而不是等一个 403 回来。
|
||||
*
|
||||
* ## 可观测实时推送(契约 v0.55 §3.12)
|
||||
* 概览页在已连接时把 `observability_metrics` / `observability_trace` / `observability_log`
|
||||
* 三帧合并进 [overview]:**REST 是真源**(进页面 / 手动刷新 / 断线重连各拉一次快照,
|
||||
* 日志的历史基线也由它给),帧只是叠在上面的增量。三帧都只发给 `owner` / `admin`(fail-closed),
|
||||
* 客户端按 [canReceiveObservabilityFrames] 自行降级:`viewer` 既不开连接也不合帧,
|
||||
* 但它**读得到**日志历史([canReadObservabilityLogHistory]),所以概览页上它照样看得到
|
||||
* 「最近日志」,只是那份列表不增长——这不是错误也不是空态。
|
||||
*
|
||||
* ## WS 连接:全 App 共享一条,本类**不持有**它
|
||||
* `observability_*` 三帧在 WS 上,而 WS 是**进程内共享**的那一条(`AppContainer.chatSocket`;
|
||||
* 为什么可以共享见那里的 KDoc——服务端广播 ⇒ 各 VM 各收全部帧 ⇒ 过滤逻辑一个字都不用改)。
|
||||
* 本类只订阅它,**不再自己开一条**。
|
||||
*
|
||||
* **本类任何路径都不 connect / close 它**:
|
||||
* - 该不该连着由**登录态**决定,主人是 `SessionsViewModel`(它一进登录态就存在,且活在 Activity 级
|
||||
* `ViewModelStore` 里);本类只在**某个屏幕**出现时才创建,让它插一手就会把「关掉管理页」
|
||||
* 变成「关掉全 App 的连接」;
|
||||
* - `viewer` 也不会因此断连:这三帧服务端本就 fail-closed,本类又用 [canReceiveObservabilityFrames]
|
||||
* 逐帧再挡一次(见 [onEvent]),所以「连接在不在」与「本类收不收到」无关。
|
||||
*
|
||||
* 本类只关心这三个帧类型,收不到也不会让功能失效——REST 会兜住。
|
||||
*/
|
||||
class AdminViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
@@ -57,6 +103,48 @@ class AdminViewModel(private val container: AppContainer) : ViewModel() {
|
||||
private val _backthink = MutableStateFlow(AdminUiState<List<AdminBackthinkItemDto>>())
|
||||
val backthink: StateFlow<AdminUiState<List<AdminBackthinkItemDto>>> = _backthink.asStateFlow()
|
||||
|
||||
/**
|
||||
* 全 App 共享的那条推送连接:本类只订阅它,**不连接也不关闭**(理由见类注释——
|
||||
* 它的主人是「登录态」那条路径,不是某个屏幕)。
|
||||
*/
|
||||
private val socket: ChatSocket = container.chatSocket
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
container.authManager.state.collect { auth ->
|
||||
when (auth) {
|
||||
SessionState.Unknown -> Unit
|
||||
|
||||
// 登出:只丢掉日志基线(`null` = 没有基线 ⇒ 区块立刻整段收起),不留下一个换账号后
|
||||
// 还挂着上个账号数据的列表。**不碰连接**——那是登录态那条路径的事(见类注释),
|
||||
// 这时主会话的流式与抽屉的未读都还挂着它。
|
||||
SessionState.LoggedOut -> {
|
||||
_overview.update { state ->
|
||||
state.copy(data = state.data?.copy(logs = null))
|
||||
}
|
||||
}
|
||||
|
||||
// 登录:**什么都不做**。连接该不该在由登录态决定(主人是 `SessionsViewModel`);
|
||||
// `viewer` 也不再因此断开——它收不到这三帧是服务端 fail-closed,本类另有
|
||||
// [canReceiveObservabilityFrames] 逐帧再挡一次(见 [onEvent]),断开连接并不能
|
||||
// 多挡掉什么,却会掐掉别的屏幕。
|
||||
is SessionState.LoggedIn -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch { socket.events.collect(::onEvent) }
|
||||
viewModelScope.launch { reloadOnConnect() }
|
||||
}
|
||||
|
||||
/**
|
||||
* **刻意不关连接**:`socket` 是**全 App 共享**的那一条,而本 VM 只随「离开管理页 / Activity 结束」
|
||||
* 被销毁——在 `onCleared` 里 close 会把主会话的流式与抽屉的未读一起掐断。
|
||||
* 断开只由**登录态**表达(见类注释)。
|
||||
*/
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/** 进入页面时调用;每次进入都重新取数(监控数据要新鲜)。 */
|
||||
fun load(page: AdminPage, role: AdminRole) {
|
||||
if (!canAccess(role, page)) {
|
||||
@@ -65,7 +153,7 @@ class AdminViewModel(private val container: AppContainer) : ViewModel() {
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (page) {
|
||||
AdminPage.Overview -> loadOverview()
|
||||
AdminPage.Overview -> loadOverview(role)
|
||||
AdminPage.Personas -> loadList(_personas) { container.adminRepository.personas() }
|
||||
AdminPage.Memories -> loadList(_memories) { container.adminRepository.memories() }
|
||||
AdminPage.Backthink -> loadList(_backthink) { container.adminRepository.backthink() }
|
||||
@@ -73,21 +161,41 @@ class AdminViewModel(private val container: AppContainer) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 概览页两个请求并发;任一 403 即整页无权限,其它失败则保留已到的部分。 */
|
||||
private suspend fun loadOverview() {
|
||||
_overview.value = _overview.value.copy(loading = true, failure = null)
|
||||
/**
|
||||
* 概览页三路请求并发;任一 403 即整页无权限(聚合进 [AdminUiState.failure]),
|
||||
* 各路自己的失败原因另外记进 [AdminOverviewData],让每个区块说自己的话。
|
||||
*
|
||||
* 三路都**以本次结果为准**:帧送的是增量,而 REST 是全量(metrics 是整份快照、
|
||||
* trace 含所有 span、日志是那份环形缓冲的尾部)。请求在飞期间到达的帧会被这次快照盖掉——
|
||||
* 那几条要么本来就在快照里,要么是快照取数之后新产生的、下一拍帧或下次刷新会再带回,
|
||||
* 不值得为这点竞态引入版本号。
|
||||
*
|
||||
* 日志这条在 W10 是例外(帧是唯一来源,重拉会把累积的列表永久抹掉);接了 REST 之后
|
||||
* 例外消失:**基线随时可以重建**,所以这里与另两路同口径,一次失败不留下「看着像当前、
|
||||
* 其实停在过去」的列表(见 [AdminOverviewData.logs])。
|
||||
*/
|
||||
private suspend fun loadOverview(role: AdminRole) {
|
||||
_overview.update { it.copy(loading = true, failure = null) }
|
||||
val repository = container.adminRepository
|
||||
val (metrics, trace) = coroutineScope {
|
||||
val (metrics, trace, logs) = coroutineScope {
|
||||
val metricsCall = async { repository.metrics() }
|
||||
val traceCall = async { repository.trace() }
|
||||
metricsCall.await() to traceCall.await()
|
||||
// 日志历史按其角色的档位决定读不读(viewer 也读得到);不够档的页在 load() 就被拦下了。
|
||||
val logsCall = if (canReadObservabilityLogHistory(role)) async { repository.logs() } else null
|
||||
Triple(metricsCall.await(), traceCall.await(), logsCall?.await())
|
||||
}
|
||||
val failures = listOfNotNull(metrics.exceptionOrNull(), trace.exceptionOrNull())
|
||||
.map { it.toAdminFailure() }
|
||||
val failures = listOfNotNull(metrics, trace, logs)
|
||||
.mapNotNull { it.exceptionOrNull()?.toAdminFailure() }
|
||||
_overview.value = AdminUiState(
|
||||
data = AdminOverviewData(
|
||||
metrics = metrics.getOrNull(),
|
||||
metricsFailure = metrics.exceptionOrNull()?.toAdminFailure(),
|
||||
trace = trace.getOrNull().orEmpty(),
|
||||
traceFailure = trace.exceptionOrNull()?.toAdminFailure(),
|
||||
// 归一化到与帧合并同一套不变式(去重 + 时间倒序 + 窗口 200),
|
||||
// 于是「REST 基线」与「基线 + 帧」在 UI 眼里完全同形。
|
||||
logs = logs?.getOrNull()?.let(::normalizeLogEntries),
|
||||
logsFailure = logs?.exceptionOrNull()?.toAdminFailure(),
|
||||
),
|
||||
failure = failures.firstOrNull { it is AdminFailure.Forbidden } ?: failures.firstOrNull(),
|
||||
)
|
||||
@@ -111,4 +219,90 @@ class AdminViewModel(private val container: AppContainer) : ViewModel() {
|
||||
AdminPage.Backthink -> _backthink.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下行帧收敛:只认可观测推送三帧,且**逐帧再判一次角色门**——帧是自己收的,
|
||||
* 但「连接当时角色够档」不等于「此刻够档」(重登换角色只换了同一实例的连接)。
|
||||
* 不够档就整帧丢弃(fail-closed),而不是先渲染再解释。
|
||||
*/
|
||||
private fun onEvent(event: WsEvent) {
|
||||
if (!canReceiveObservabilityFrames(currentRole())) return
|
||||
when (event) {
|
||||
is WsEvent.ObservabilityMetrics -> onMetricsFrame(event)
|
||||
is WsEvent.ObservabilityTrace -> onTraceFrame(event)
|
||||
is WsEvent.ObservabilityLog -> onLogFrame(event)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentRole(): AdminRole =
|
||||
AdminRole.of((container.authManager.state.value as? SessionState.LoggedIn)?.role)
|
||||
|
||||
/**
|
||||
* 指标帧 = **整份替换**(载荷就是与 REST 同形的扁平快照本体,服务端只在它变化时才发)。
|
||||
*
|
||||
* 两道闸:帧里没有载荷对象([WsEvent.ObservabilityMetrics.metrics] 为 null)时跳过,
|
||||
* 免得把界面上的真实指标写成全零;还没有 REST 基线([AdminUiState.data] 为 null,
|
||||
* 即首屏那次引导读取尚未回来)时也不落地——增量只能叠在基线上,凭空造一份
|
||||
* 「只有指标、trace 为空」的状态会把「还没拉到」渲染成「没有 trace」。
|
||||
*/
|
||||
private fun onMetricsFrame(event: WsEvent.ObservabilityMetrics) {
|
||||
val metrics = event.metrics ?: return
|
||||
_overview.update { state ->
|
||||
val data = state.data ?: return@update state
|
||||
state.copy(data = data.copy(metrics = metrics))
|
||||
}
|
||||
}
|
||||
|
||||
/** trace 帧是**增量**:与已有快照按 `id` 去重合并(见 [mergeTraceSpans])。 */
|
||||
private fun onTraceFrame(event: WsEvent.ObservabilityTrace) {
|
||||
if (event.spans.isEmpty()) return
|
||||
_overview.update { state ->
|
||||
val data = state.data ?: return@update state
|
||||
state.copy(data = data.copy(trace = mergeTraceSpans(data.trace, event.spans)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志帧是**增量**:叠在 REST 基线上(去重 + 按时间倒序 + 窗口 200,见 [mergeLogEntries])。
|
||||
*
|
||||
* 没有基线([AdminOverviewData.logs] 为 null)时**整帧丢弃**:增量只能叠在基线上,
|
||||
* 凭空造一份「只有帧、没有历史」的列表,会把「还没拉到」渲染成「就这几条」。
|
||||
* 丢掉的那几条不会永久消失——服务端的环形缓冲留着 2000 条,下次成功的引导读取会带回最新
|
||||
* 200 条(只要这期间没产生超过一个窗口的新日志,它们就在里面)。
|
||||
*/
|
||||
private fun onLogFrame(event: WsEvent.ObservabilityLog) {
|
||||
if (event.entries.isEmpty()) return
|
||||
_overview.update { state ->
|
||||
val data = state.data ?: return@update state
|
||||
val baseline = data.logs ?: return@update state
|
||||
state.copy(data = data.copy(logs = mergeLogEntries(baseline, event.entries)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每次「连上」(含首次连接与断线重连)都补一次引导读取(REST 兜底)。
|
||||
*
|
||||
* 为什么重连后必须重读:推送是**增量**,总线也不回放——断线窗口里的帧是永久丢了,
|
||||
* 那份增量流不再可信。而且服务端只为「有在线 owner/admin 连接」的凭据保留订阅
|
||||
* (`observability_push.go` 的 `prune`),订阅可能在连接还没上线的窗口里被剪掉、
|
||||
* 推送循环随之退出;重读 REST 同时把订阅重新建立起来(契约 §3.12:读取即订阅握手)。
|
||||
* 首次连接同样要补,正是为了兜住「页面先读、连接后上」这个顺序。
|
||||
*
|
||||
* 只在**已经拿到过引导数据**之后触发([AdminUiState.data] 非空):还没读过的那一次
|
||||
* 由页面自己的 `load` 负责,不重复请求。`StateFlow` 只在值变化时发射,所以这里
|
||||
* 一次连接只会补一次。
|
||||
*/
|
||||
private suspend fun reloadOnConnect() {
|
||||
socket.state.collect { state ->
|
||||
if (state == WsState.Connected) reloadOverview()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reloadOverview() {
|
||||
val role = currentRole()
|
||||
if (!canReceiveObservabilityFrames(role)) return
|
||||
if (_overview.value.data == null) return
|
||||
viewModelScope.launch { loadOverview(role) }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -10,13 +11,31 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import kotlinx.coroutines.delay
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.ui.admin.AdminHomeScreen
|
||||
import top.yeij.cyrene.ui.common.MiuixDrawer
|
||||
import top.yeij.cyrene.ui.sessions.SessionDrawerContent
|
||||
import top.yeij.cyrene.ui.sessions.SessionsViewModel
|
||||
import top.yeij.cyrene.ui.settings.AboutScreen
|
||||
import top.yeij.cyrene.ui.settings.SettingsScreen
|
||||
|
||||
/** 「这个会话在服务端已经没了」那句告知挂多久(毫秒):它描述一次刚发生的动作,不是持续状态。 */
|
||||
private const val SESSION_GONE_NOTICE_MILLIS = 8_000L
|
||||
|
||||
/**
|
||||
* 会话在别处被删掉时那句如实告知。
|
||||
*
|
||||
* 它必须由**这一层**持有,不能挂在 `ChatViewModel` 上:`onSessionGone` 一被调用,
|
||||
* `activeSessionId` 就会被收敛回主会话,ChatScreen 的槽位(与 VM key 里的会话 id)随之变化 ——
|
||||
* 于是**承载横幅的那段组合被销毁重建**;而那个 VM 这会儿正被交出去(本端删除那条路走
|
||||
* `SessionsViewModel.deleteSession` 里的 `ChatSessionSlots.reclaim`,别处删除这条走 404 收敛),
|
||||
* 挂在它状态上的横幅连这一帧都活不过。这一层的 `remember` 活得比它久。
|
||||
*/
|
||||
private const val SESSION_GONE_MESSAGE = "这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话"
|
||||
|
||||
|
||||
/**
|
||||
* 聊天页 + 侧边栏(会话切换)+ 设置页 + 管理员监控。
|
||||
* 返回键逐层回退:管理/设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。
|
||||
@@ -33,9 +52,40 @@ fun ChatHomeScreen(
|
||||
var drawerOpen by remember { mutableStateOf(false) }
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
var showAdmin by remember { mutableStateOf(false) }
|
||||
var showAbout by remember { mutableStateOf(false) }
|
||||
|
||||
// 当前会话在别处被删掉时那句告知(见 [SESSION_GONE_MESSAGE])。放在这一层是因为承载它的
|
||||
// 那段组合(ChatScreen)会在切回主会话的瞬间被销毁重建,而这里的 remember 活得比它久。
|
||||
var sessionNotice by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(sessionNotice) {
|
||||
if (sessionNotice != null) {
|
||||
delay(SESSION_GONE_NOTICE_MILLIS)
|
||||
sessionNotice = null
|
||||
}
|
||||
}
|
||||
|
||||
// 「点她那条通知进来 ⇒ 看主会话」(W53N)。意图活在进程里(通知可能在 App 还没起来、甚至还没
|
||||
// 登录时就被点,见 ProactiveTapIntake),这里只负责把它落地成用户能看见的两件事:
|
||||
// 回到聊天页、并切到主会话。走的是用户点抽屉里那一行的同一条路(select),不另造机制。
|
||||
val pendingProactiveTap by container.proactiveTapIntake.openMainSession.collectAsState()
|
||||
LaunchedEffect(pendingProactiveTap) {
|
||||
if (!pendingProactiveTap) return@LaunchedEffect
|
||||
container.proactiveTapIntake.consume()
|
||||
// 通知说的是「她刚在主会话里说话」,所以落点必须是**那一页**:设置页 / 管理页 / 关于 /
|
||||
// 抽屉还开着的话,用户点进来看到的仍是它们,这条通知就等于没兑现。
|
||||
drawerOpen = false
|
||||
showAbout = false
|
||||
showSettings = false
|
||||
showAdmin = false
|
||||
sessionsViewModel.select(SessionSummaryDto.MAIN_SESSION_ID)
|
||||
}
|
||||
|
||||
BackHandler(enabled = showAdmin) { showAdmin = false }
|
||||
BackHandler(enabled = showSettings) { showSettings = false }
|
||||
// 「关于」嵌在设置页里,所以两个条件必须**互斥**:在关于页时设置页的处理器关掉,
|
||||
// 返回才只退一层(关于 → 设置),不会一步跳回聊天。
|
||||
BackHandler(enabled = showAbout) { showAbout = false }
|
||||
BackHandler(enabled = showSettings && !showAbout) { showSettings = false }
|
||||
BackHandler(enabled = drawerOpen) { drawerOpen = false }
|
||||
|
||||
when {
|
||||
@@ -44,10 +94,13 @@ fun ChatHomeScreen(
|
||||
onBack = { showAdmin = false },
|
||||
)
|
||||
|
||||
showAbout -> AboutScreen(onBack = { showAbout = false })
|
||||
|
||||
showSettings -> SettingsScreen(
|
||||
container = container,
|
||||
onBack = { showSettings = false },
|
||||
onLogout = onLogout,
|
||||
onOpenAbout = { showAbout = true },
|
||||
)
|
||||
|
||||
else -> MiuixDrawer(
|
||||
@@ -66,7 +119,26 @@ fun ChatHomeScreen(
|
||||
// 静默是为了不闪「同步中…」——屏幕上本来就有内容。
|
||||
sessionsViewModel.refresh(silent = true)
|
||||
},
|
||||
onLogout = onLogout,
|
||||
// 当前会话在别处被删了(`404 session_not_found`)⇒ 先立起那句如实告知,再重拉列表:
|
||||
// `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话
|
||||
// (`resolveActiveSession`),抽屉里那条也跟着消失。
|
||||
//
|
||||
// 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key,
|
||||
// 那段组合连同挂在 VM 状态上的东西一起被丢弃重建,晚一步就什么都看不到了。
|
||||
//
|
||||
// ⚠️ 但**不能只靠 `refresh` 把用户送回去**:它只在**拉成功**时才做回落
|
||||
// (`resolveActiveSession`,且列表为空时刻意不回落;`.onFailure` 那条更是什么都不动)。
|
||||
// 于是「网络正不稳 / 服务端正好抖一下」时,屏幕上那句「已切回主会话」就成了一句
|
||||
// 做不到的话——用户被留在一个已经没了的会话上,没有任何出路。这正是这个告知
|
||||
// 想要修掉的那一幕,所以这里**先在本端收敛**(`select` 是用户点抽屉里那一行走的
|
||||
// **同一条**路,不另造机制),`refresh` 退回它本来的职责:与服务端对齐、把抽屉
|
||||
// 里那条去掉。副作用与用户手点主会话进入时逐字相同(就地清未读 + 推进游标)。
|
||||
onSessionGone = {
|
||||
sessionNotice = SESSION_GONE_MESSAGE
|
||||
sessionsViewModel.select(SessionSummaryDto.MAIN_SESSION_ID)
|
||||
sessionsViewModel.refresh(silent = true)
|
||||
},
|
||||
sessionNotice = sessionNotice,
|
||||
)
|
||||
},
|
||||
drawerContent = {
|
||||
@@ -80,6 +152,7 @@ fun ChatHomeScreen(
|
||||
onDelete = { sessionsViewModel.deleteSession(it.id) },
|
||||
onTogglePin = { sessionsViewModel.setPinned(it.id, !it.pinned) },
|
||||
onRename = { sessionId, title -> sessionsViewModel.renameSession(sessionId, title) },
|
||||
onStopGenerating = { sessionsViewModel.stopGenerating(it.id) },
|
||||
onOpenIot = { /* P2:IoT 页面 */ },
|
||||
onOpenAdmin = {
|
||||
drawerOpen = false
|
||||
@@ -89,6 +162,7 @@ fun ChatHomeScreen(
|
||||
drawerOpen = false
|
||||
showSettings = true
|
||||
},
|
||||
onDismissError = sessionsViewModel::dismissError,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,298 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 预览弹层的三种状态。**加载中与失败必须长得不一样**:失败要能重试,还绝不能被当成「空文件」。 */
|
||||
sealed interface DocumentPreviewState {
|
||||
|
||||
data object Loading : DocumentPreviewState
|
||||
|
||||
/** 取字节失败;[message] 是 [top.yeij.cyrene.domain.chat.documentLoadFailureMessage] 的产物。 */
|
||||
data class Failed(val message: String) : DocumentPreviewState
|
||||
|
||||
/** 已取到字节并切好段。[document] 同时是「打开 / 保存」要用的那一份。 */
|
||||
data class Ready(val document: CachedDocument, val slice: PreviewSlice) : DocumentPreviewState
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹层下方的一行告知。
|
||||
*
|
||||
* [error] 单独一位是因为「已保存」与「没有应用能打开」是**同一行**上的两种性质:都用错误色
|
||||
* 会让一次成功的保存看着像出事,都用中性色又会让失败被当成提示。
|
||||
*/
|
||||
data class DocumentPreviewNotice(val message: String, val error: Boolean = false)
|
||||
|
||||
/**
|
||||
* 附件预览弹层。
|
||||
*
|
||||
* 用 Compose 的 `Dialog` + Miuix 内容,与抽屉里那几个弹层同一套做法(抽屉只有 300dp 宽,
|
||||
* 而正文要一块能盖住全屏、可滚动的地方)。
|
||||
*
|
||||
* ## 三条出口
|
||||
* - **关闭**:只关弹层,什么都不写;
|
||||
* - **用其它应用打开**:把缓存里的那一份经 `FileProvider` 交给系统([onOpen]);
|
||||
* - **保存到下载目录**:走 `ACTION_CREATE_DOCUMENT` 让用户挑落点([onSave])。
|
||||
*
|
||||
* 后两条在字节还没到位([DocumentPreviewState.Loading] / [DocumentPreviewState.Failed])时
|
||||
* **不建可点**:点了也只能失败,不如让它们明摆着还没准备好。
|
||||
*
|
||||
* ## 正文区由 [plan] 说了算
|
||||
* 非文本([DocumentPreviewPlan.NotText],如 `application/pdf` 的字节)**不渲染正文**——本端
|
||||
* 只会把字节当 UTF-8 文本显示,一份 PDF 那样子看起来就是一屏乱码,像文件坏了。此时正文区
|
||||
* 只留 [DocumentPreviewPlan.NotText.message] 那句人话:**这既不是加载失败**(字节是取回来的,
|
||||
* 两条出口照旧可用),也不是「文件是空的」。
|
||||
*
|
||||
* 那句话有两种来源,本弹层**一视同仁地原样渲染**:本端按类型写的那句,以及服务端说「读不到」
|
||||
* 时下发的**原因原话**(契约 v0.63 的 `documents[].reason`,判定见
|
||||
* [top.yeij.cyrene.domain.chat.DocumentPreviewGate.plan])——本层不做任何改写或截断,
|
||||
* 它拿到的 `message` 就是要显示的那句话。
|
||||
*
|
||||
* ## 正文为什么是 `LazyColumn` + 分块
|
||||
* 上限是 5000 行 / 256 KB,整段塞进一个 `Text` 会让打开弹层的那一刻卡在排版上。切成
|
||||
* [top.yeij.cyrene.domain.chat.DocumentPreviewLimits.LINES_PER_CHUNK] 行一块交给 `LazyColumn`,
|
||||
* 就只测量当前可见的那几块。每块各自包一层 `SelectionContainer`,与气泡同一约定:
|
||||
* 「全选」只作用于当前那一块,不跨块。
|
||||
*
|
||||
* ## 截断必须自己说出来
|
||||
* [PreviewSlice.notice] 非空时在正文上方明写「只显示前 N」并指路另外两条出口——**绝不静默
|
||||
* 截断**:用户看到一段像是完整的正文却其实是前 256 KB,是最容易被误读的一种失败。
|
||||
*
|
||||
* ## 正文下方那一行元数据
|
||||
* 正文(非文本时是那句人话)**下方**还有一行灰色小字,写的是「这份文件是什么」的格式事实:
|
||||
* 文本给行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记,非文本给服务端嗅探所得的 mime。
|
||||
* 组装全在纯函数 [PreviewMetadata] 里(**「为什么某一项不显示」也写在那里**),这里只取证据、画一行。
|
||||
*
|
||||
* 它与上方那枚芯片刻意**不重复**:体积与服务端状态芯片上已经写着(同一个数字、同一个词,再写
|
||||
* 一遍只是噪音);字符数虽然也在芯片上,但芯片那个「8,000 字」没说清是谁的口径——本行补上
|
||||
* 「(服务端注入口径)」,这才不算一句会把用户带偏的话。
|
||||
*
|
||||
* @param target 被点开的那一枚芯片(来源判定见 [DocumentTarget.of],弹层不自己判断)。
|
||||
* @param plan 正文区能不能按文本渲染([DocumentTarget.previewPlan] 的产物,弹层不自己判断)。
|
||||
* @param notice 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败);
|
||||
* null = 没什么要说的。
|
||||
*/
|
||||
@Composable
|
||||
fun DocumentPreviewDialog(
|
||||
target: DocumentTarget,
|
||||
state: DocumentPreviewState,
|
||||
plan: DocumentPreviewPlan,
|
||||
notice: DocumentPreviewNotice?,
|
||||
onRetry: () -> Unit,
|
||||
onOpen: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val enabled = state is DocumentPreviewState.Ready
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
|
||||
.padding(20.dp),
|
||||
) {
|
||||
Text("附件预览", style = MiuixTheme.textStyles.title3)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// 文件名(服务端存储名或待发附件的显示名),长名字中略——扩展名是这里唯一有语义的部分。
|
||||
Text(
|
||||
text = target.chips.name,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// 类型 · 体积 · 字数 · 状态:**复用气泡那枚芯片**,不另写一套标签。
|
||||
DocumentChipRow(chips = listOf(target.chips))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
when (state) {
|
||||
DocumentPreviewState.Loading -> PreviewHint("正在取回内容…")
|
||||
|
||||
is DocumentPreviewState.Failed -> {
|
||||
// 失败**绝不渲染成空文件**:一句话说清 + 一个重试出口。
|
||||
PreviewHint(state.message, error = true)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
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),
|
||||
)
|
||||
}
|
||||
PreviewBody(state.slice)
|
||||
}
|
||||
|
||||
// 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用),
|
||||
// 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。
|
||||
is DocumentPreviewPlan.NotText -> PreviewHint(plan.message)
|
||||
}
|
||||
PreviewMetadataLine(state = state, plan = plan, target = target)
|
||||
}
|
||||
}
|
||||
|
||||
notice?.let { line ->
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = line.message,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = if (line.error) {
|
||||
MiuixTheme.colorScheme.error
|
||||
} else {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
title = "用其它应用打开",
|
||||
summary = "交给系统里能读这个类型的应用",
|
||||
onClick = if (enabled) onOpen else null,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
title = "保存到下载目录",
|
||||
summary = "自己挑一个落点,内容与这里看到的是同一份",
|
||||
onClick = if (enabled) onSave else null,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
// 契约缺口如实写在界面上:服务端按内容寻址落盘,只回存储名,原始文件名不可得。
|
||||
Text(
|
||||
text = "服务端只保留了内容寻址的存储名,保存下来的文件名就是它,原始文件名不会返回给客户端。",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton("关闭", onClick = onDismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文**下方**那一行灰色小字:关于这份文件的格式元数据([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) {
|
||||
val chunks = remember(slice.text) { DocumentPreview.chunks(slice.text) }
|
||||
if (chunks.isEmpty()) {
|
||||
PreviewHint("(这份文件是空的)")
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = PREVIEW_BODY_MAX_HEIGHT)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainer, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
// 键必须是下标:内容相同的两块(整篇都是同一行的那种文件)用内容当键会撞键。
|
||||
itemsIndexed(items = chunks, key = { index, _ -> index }) { _, chunk ->
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = chunk,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewHint(text: String, error: Boolean = false) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = if (error) MiuixTheme.colorScheme.error else MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
|
||||
/** 正文区最大高度:再高就把三条出口挤出窄屏的可见区。 */
|
||||
private val PREVIEW_BODY_MAX_HEIGHT = 320.dp
|
||||
@@ -0,0 +1,501 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.FileProvider
|
||||
import coil3.compose.AsyncImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.data.repository.CachedDocument
|
||||
import top.yeij.cyrene.data.repository.ImageCacheStore
|
||||
import top.yeij.cyrene.domain.chat.DocumentOpen
|
||||
import top.yeij.cyrene.domain.chat.DocumentOpenPlan
|
||||
import top.yeij.cyrene.domain.chat.ImageDataUri
|
||||
import top.yeij.cyrene.domain.chat.ImageFormats
|
||||
import top.yeij.cyrene.domain.chat.ImageNames
|
||||
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
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
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.theme.MiuixTheme
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 全屏查看的三种状态。**加载中 / 失败 / 就绪必须长得不一样**:
|
||||
* 取字节失败要能重试,而且**绝不能被画成一张空白图**——「按了没反应」和「图是白的」是两种
|
||||
* 都会被误读成「这张图就是这样」的失败。
|
||||
*/
|
||||
sealed interface ImagePreviewState {
|
||||
|
||||
data object Loading : ImagePreviewState
|
||||
|
||||
/** 取字节失败;[message] 是 [imageLoadFailureMessage] 的产物。 */
|
||||
data class Failed(val message: String) : ImagePreviewState
|
||||
|
||||
/**
|
||||
* 字节已落进缓存。[cached] 同时是渲染、交给别的 App、写进下载目录的那**同一份**;
|
||||
* [openType] 是「用其它应用打开」要声明的 MIME(见 [ImageOpenTypes.openType])。
|
||||
*/
|
||||
data class Ready(val cached: CachedDocument, val openType: String) : ImagePreviewState
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片全屏查看弹层。
|
||||
*
|
||||
* 与附件预览([DocumentPreviewDialog])是同一套做法的另一份:`Dialog` + Miuix 内容、
|
||||
* 缓存与落盘复用 [DocumentCache],只是这一份要**铺满全屏**(`usePlatformDefaultWidth = false`)
|
||||
* 才谈得上 fit-center 与放大。
|
||||
*
|
||||
* ## 手势
|
||||
* - **双指捏合**缩放([ImageZoom.zoomAround],以双指中点为不动点)、**单指拖动**平移;
|
||||
* - **双击**在 1x ↔ 2x 之间切换(以双击点为不动点;收回 fit 时位移自动归零);
|
||||
* - 位移与缩放全部经 [ImageZoom] 钳制:fit 态下拖不动(内容没超出容器),放大后拖不到
|
||||
* 露出空白,缩放范围恒在 1x…3x 之间。
|
||||
*
|
||||
* ## 两个出口(与 W11 完全相同的那两条)
|
||||
* - **用其它应用打开**:把缓存里那一份经 **FileProvider** 交出去,类型用**图片自己的 mime**
|
||||
* (嗅探 > 平台声明 > 扩展名 > 图片通配);系统里没有 App 能开时**如实说一句**,
|
||||
* 并指路「保存到下载目录」——绝不静默失败。
|
||||
* - **保存到下载目录**:走 `ACTION_CREATE_DOCUMENT`,类型**恒为通配**([ImageOpenTypes.SAVE_MIME])
|
||||
* ——否则 SAF 会按具体 mime 给文件名补扩展名。
|
||||
*
|
||||
* ## 退出
|
||||
* 右上角关闭按钮 + 系统返回键([BackHandler];`Dialog` 自己也会把返回交给 `onDismissRequest`,
|
||||
* 两者通向同一个「只关弹层、什么都不写」)。缩放状态活在弹层的组合里,**关闭即复位**,
|
||||
* 再打开永远从 fit 开始。
|
||||
*
|
||||
* @param source 被点开的那张图(来源判定见 [ImageSource.of],弹层不自己判断)。
|
||||
* @param store 取字节 + 落盘的缓存层(与附件共用 `cacheDir/documents/` 那一层)。
|
||||
*/
|
||||
@Composable
|
||||
fun ImagePreviewDialog(
|
||||
source: ImageSource,
|
||||
store: ImageCacheStore,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// 重试计数:取字节失败与解码失败共用它,一次「重试」把两条路都重来一遍。
|
||||
var retry by remember(source) { mutableStateOf(0) }
|
||||
// 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败)。
|
||||
// 复用附件弹层那个类型:它就是「弹层下方的一行告知」,与文档无关。
|
||||
var notice by remember(source) { mutableStateOf<DocumentPreviewNotice?>(null) }
|
||||
// Coil 解不开这份字节(拿到了、但不是它能认的图):与「取字节失败」分开说——两件事
|
||||
// 要用户做的事不一样,前者可以重试,后者换了网络也没用。
|
||||
var undecodable by remember(source, retry) { mutableStateOf(false) }
|
||||
|
||||
val state by produceState<ImagePreviewState>(
|
||||
initialValue = ImagePreviewState.Loading,
|
||||
key1 = source,
|
||||
key2 = retry,
|
||||
) {
|
||||
value = ImagePreviewState.Loading
|
||||
value = store.load(source).fold(
|
||||
onSuccess = { cached ->
|
||||
ImagePreviewState.Ready(cached, imageOpenType(context, source, cached))
|
||||
},
|
||||
onFailure = { ImagePreviewState.Failed(imageLoadFailureMessage(it)) },
|
||||
)
|
||||
}
|
||||
|
||||
// 预览内容下方那一行元数据要用的事实:像素与 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(
|
||||
ActivityResultContracts.CreateDocument(ImageOpenTypes.SAVE_MIME),
|
||||
) { destination ->
|
||||
val document = (state as? ImagePreviewState.Ready)?.cached
|
||||
if (destination == null || document == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
context.contentResolver.openOutputStream(destination)?.use {
|
||||
it.write(document.bytes)
|
||||
} ?: error("no output stream for $destination")
|
||||
}.isSuccess
|
||||
}
|
||||
notice = if (saved) {
|
||||
DocumentPreviewNotice("已保存")
|
||||
} else {
|
||||
DocumentPreviewNotice("保存失败,请换一个位置再试", error = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openElsewhere() {
|
||||
val ready = state as? ImagePreviewState.Ready ?: return
|
||||
val contentUri = runCatching {
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", ready.cached.file)
|
||||
}.getOrNull()
|
||||
if (contentUri == null) {
|
||||
notice = DocumentPreviewNotice("无法把这张图交给其它应用:本地缓存文件不存在", error = true)
|
||||
return
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(contentUri, ready.openType)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val hasHandler = context.packageManager
|
||||
.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null
|
||||
// 裁决与文案复用附件那条路([DocumentOpen]):语义完全一样——「交给系统里能读这个类型的
|
||||
// 应用」,没有能处理的就如实说一句并指路保存。
|
||||
when (val plan = DocumentOpen.plan(ready.openType, hasHandler)) {
|
||||
DocumentOpenPlan.HandOff -> try {
|
||||
context.startActivity(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
notice = DocumentPreviewNotice(DocumentOpen.noHandlerMessage(ready.openType), error = true)
|
||||
}
|
||||
|
||||
is DocumentOpenPlan.NoHandler -> notice = DocumentPreviewNotice(plan.message, error = true)
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
// 系统返回键:只关弹层,绝不落到聊天页的返回层级上([ChatHomeScreen] 那几层返回不变)。
|
||||
BackHandler(enabled = true) { onDismiss() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MiuixTheme.colorScheme.surface),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when (val current = state) {
|
||||
ImagePreviewState.Loading -> CenteredHint("正在加载图片…")
|
||||
|
||||
is ImagePreviewState.Failed -> FailurePane(
|
||||
message = current.message,
|
||||
onRetry = { retry++ },
|
||||
)
|
||||
|
||||
is ImagePreviewState.Ready -> if (undecodable) {
|
||||
FailurePane(
|
||||
message = "这张图片解不开(内容可能不是图片)",
|
||||
onRetry = { retry++ },
|
||||
)
|
||||
} else {
|
||||
ZoomableImage(
|
||||
file = current.cached.file,
|
||||
onUndecodable = { undecodable = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExitPane(
|
||||
ready = ready,
|
||||
metadata = metadata,
|
||||
notice = notice,
|
||||
note = nameNoteFor(source),
|
||||
onOpen = { openElsewhere() },
|
||||
onSave = {
|
||||
val name = (state as? ImagePreviewState.Ready)?.cached?.file?.name
|
||||
createDocument.launch(name ?: FALLBACK_IMAGE_NAME)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 右上角关闭:底部那一列出口只讲「拿这张图去做什么」,退出这件事单独放在它的位置。
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Basic.Close,
|
||||
contentDescription = "关闭",
|
||||
tint = MiuixTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可缩放平移的图片。
|
||||
*
|
||||
* 渲染的**是缓存里那一份文件**(不是原始 URL):取字节与落盘已经在 [ImageCacheStore] 里做完,
|
||||
* 于是「看得到的」与「交给别的 App 的」是同一条路上的同一份字节——不会出现「预览能看、打开
|
||||
* 却失败」。Coil 只负责解码本地文件(HEIC 这类本端说不清类型的图在这里也能看,解码是系统的事)。
|
||||
*
|
||||
* 两个 `pointerInput` 分开挂:一个认双击,一个认捏合与拖动。它们不会互相吃掉——
|
||||
* 拖动一旦过了 touch slop 就被 `detectTransformGestures` 消费,双击因此不会被拖动误触。
|
||||
* 键取两个尺寸:测量结果到达时重建手势检测器,避免闭包里留着上一次的尺寸。
|
||||
*
|
||||
* @param onUndecodable 字节拿到了但 Coil 解不开(例如服务端被代理换成了 HTML 错误页)。
|
||||
*/
|
||||
@Composable
|
||||
private fun ZoomableImage(file: File, onUndecodable: () -> Unit) {
|
||||
var containerSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var imageSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val zoom = remember { mutableStateOf(ZoomPan()) }
|
||||
val container = ImageSize(containerSize.width.toFloat(), containerSize.height.toFloat())
|
||||
val image = ImageSize(imageSize.width.toFloat(), imageSize.height.toFloat())
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clipToBounds()
|
||||
.onSizeChanged { containerSize = it }
|
||||
.pointerInput(imageSize, containerSize) {
|
||||
detectTransformGestures { centroid, pan, zoomChange, _ ->
|
||||
val focusX = centroid.x - containerSize.width / 2f
|
||||
val focusY = centroid.y - containerSize.height / 2f
|
||||
val zoomed = ImageZoom.zoomAround(
|
||||
current = zoom.value,
|
||||
targetScale = zoom.value.scale * zoomChange,
|
||||
focusX = focusX,
|
||||
focusY = focusY,
|
||||
container = container,
|
||||
image = image,
|
||||
)
|
||||
zoom.value = ImageZoom.pan(zoomed, pan.x, pan.y, container, image)
|
||||
}
|
||||
}
|
||||
.pointerInput(imageSize, containerSize) {
|
||||
detectTapGestures(
|
||||
onDoubleTap = { position ->
|
||||
zoom.value = ImageZoom.doubleTap(
|
||||
current = zoom.value,
|
||||
focusX = position.x - containerSize.width / 2f,
|
||||
focusY = position.y - containerSize.height / 2f,
|
||||
container = container,
|
||||
image = image,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = file,
|
||||
contentDescription = "查看的图片",
|
||||
contentScale = ContentScale.Fit,
|
||||
onSuccess = { result ->
|
||||
imageSize = IntSize(result.result.image.width, result.result.image.height)
|
||||
},
|
||||
onError = { onUndecodable() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = zoom.value.scale
|
||||
scaleY = zoom.value.scale
|
||||
translationX = zoom.value.offsetX
|
||||
translationY = zoom.value.offsetY
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部那一列出口(与附件弹层同一形态)。
|
||||
*
|
||||
* 最上面那一行是**预览内容下方**的格式元数据([PreviewMetadata] 的产物,可能是空串——
|
||||
* 那就整行不画,绝不留一个空行或一排「未知」);再下面是 [notice](刚才那个动作的结果)。
|
||||
*
|
||||
* 字节还没到位([ImagePreviewState.Loading] / [ImagePreviewState.Failed])时两条出口**不给点**:
|
||||
* 点了也只能失败,不如让它们明摆着还没准备好。但**关闭**任何时候都在(右上角那个按钮)。
|
||||
*/
|
||||
@Composable
|
||||
private fun ExitPane(
|
||||
ready: ImagePreviewState.Ready?,
|
||||
metadata: String,
|
||||
notice: DocumentPreviewNotice?,
|
||||
note: String,
|
||||
onOpen: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.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,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = if (line.error) {
|
||||
MiuixTheme.colorScheme.error
|
||||
} else {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = EXIT_TEXT_MARGIN, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
title = "用其它应用打开",
|
||||
summary = ready?.let { "交给系统里能看 ${it.openType} 的应用" } ?: "图片还在加载",
|
||||
onClick = if (ready == null) null else onOpen,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
title = "保存到下载目录",
|
||||
summary = "自己挑一个落点,内容与这里看到的是同一份",
|
||||
onClick = if (ready == null) null else onSave,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = note,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(horizontal = EXIT_TEXT_MARGIN, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredHint(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
|
||||
/** 取字节失败 / 解不开:一句话说清 + 一个重试出口(**绝不画成一张空白图**)。 */
|
||||
@Composable
|
||||
private fun FailurePane(message: String, onRetry: () -> Unit) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
TextButton("重试", onClick = onRetry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「用其它应用打开」要声明的类型。
|
||||
*
|
||||
* 服务端来源在地址里就有名字(那扩展名来自服务端自己那一次嗅探);本地来源要问一次平台
|
||||
* (`ContentResolver.getType`,一次 IPC,只在打开时做,不做缓存);内联来源的类型写在
|
||||
* `data:` 头里。三者都只是**候选**,最终由 [ImageFormats.sniff](字节)压过它们——
|
||||
* 字节是唯一裁决,与文档那条路同一纪律。
|
||||
*/
|
||||
private fun imageOpenType(context: Context, source: ImageSource, cached: CachedDocument): String {
|
||||
val declared = when (source) {
|
||||
is ImageSource.Local -> runCatching {
|
||||
context.contentResolver.getType(Uri.parse(source.raw))
|
||||
}.getOrNull()
|
||||
|
||||
is ImageSource.Inline -> ImageDataUri.declaredMime(source.raw)
|
||||
is ImageSource.Server -> null
|
||||
}
|
||||
val name = when (source) {
|
||||
is ImageSource.Server -> ImageNames.fromServerUrl(source.raw)
|
||||
// 内联与本地都没有服务端名字,用缓存里那个(内容寻址 + 嗅探所得的扩展名)。
|
||||
else -> cached.file.name
|
||||
}
|
||||
return ImageOpenTypes.openType(ImageFormats.sniff(cached.bytes), declared, name)
|
||||
}
|
||||
|
||||
/** 名字这件事按来源如实说:服务端来源的名字是它的存储名,还没上去的那张是本端自己算的。 */
|
||||
private fun nameNoteFor(source: ImageSource): String = when (source) {
|
||||
is ImageSource.Server ->
|
||||
"服务端只保留了内容寻址的存储名,保存下来的文件名就是它,原始文件名不会返回给客户端。"
|
||||
|
||||
else ->
|
||||
"这张图还没有服务端存储名,保存时用的是本端按内容(sha256)算出来的名字。"
|
||||
}
|
||||
|
||||
/** 建议文件名的兜底:理论上到不了(缓存名恒非空),只是不让一个空名字进 SAF。 */
|
||||
private const val FALLBACK_IMAGE_NAME = "image.png"
|
||||
|
||||
/** 底部告知文字与左侧卡片的对齐余量([PreferenceCard] 自己带 12dp 外边距)。 */
|
||||
private val EXIT_TEXT_MARGIN = 20.dp
|
||||
@@ -0,0 +1,70 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
|
||||
/**
|
||||
* 一次发送切出来的一条消息([imageSendPlan] 的元素)。
|
||||
*
|
||||
* [imageUri] 是这条消息要带的那张图(契约里 `chat.image` 是**单图字段**);[documents] 是这条
|
||||
* 消息要带的非图片附件(`chat.documents` 是数组,但一次只挂在一条消息上)。
|
||||
*/
|
||||
data class PlannedMessage(
|
||||
val text: String,
|
||||
val imageUri: String?,
|
||||
val documents: List<DraftDocument>,
|
||||
)
|
||||
|
||||
/** 一次发送的切分结果([imageSendPlan] 的返回值)。 */
|
||||
data class ImageSendPlan(
|
||||
/** 要投递的消息,顺序 = 用户选图的顺序。**至少一条**:没有图时它就是承载正文与附件的那条。 */
|
||||
val messages: List<PlannedMessage>,
|
||||
/** 超过 [ShareRules.MAX_IMAGES] 而**不会**发出去的张数(0 = 一张没少)。 */
|
||||
val truncatedImages: Int,
|
||||
) {
|
||||
/** 这次真正会发出去的图片(顺序不变)。超限告知里说的「前 N 张」,N 就是它的长度。 */
|
||||
val imagesToSend: List<String> get() = messages.mapNotNull { it.imageUri }
|
||||
}
|
||||
|
||||
/**
|
||||
* 「一次发送(正文 + N 张图 + 若干附件)要切成几条消息、每条带什么、超限几张」——**纯函数**,
|
||||
* 与 Android 无关、可单测(见 `ImageSendPlanTest`)。
|
||||
*
|
||||
* ## 为什么 N 张图 = N 条消息
|
||||
* 契约里 `chat.image` 是**单图字段**:一次上行只能带一张。所以多选进来的 N 张只能落成 N 条消息,
|
||||
* 顺序 = 用户选图的顺序,而**第一条**带上正文与全部附件——正文是用户打的这一段字、附件是挂在
|
||||
* 这条消息上的数组,两者本来就只该出现一次。第二条起每条只有一张图、正文为空:空正文的那几条
|
||||
* 不会凭空替用户多说一个字。
|
||||
*
|
||||
* ## 上限只在这里判一次
|
||||
* 上限 [ShareRules.MAX_IMAGES] 与分享路径用**同一个数**(4):两条路都是「一次最多接住几张图」。
|
||||
* 多出来的张数由 [ImageSendPlan.truncatedImages] 如实报出来,调用方据此说一句
|
||||
* (文案 [ShareRules.truncatedImagesNotice])——**绝不静默截断**。
|
||||
*
|
||||
* ## N = 0 为什么也回一条
|
||||
* 没有图时这一次发送仍然是一条普通消息(正文 + 附件),它是同一条规则的空形态。把「有没有东西
|
||||
* 可发」留给 [hasSendableContent] 判,这里只回答「要切几条、每条带什么」——两个判据各管一件事,
|
||||
* 谁也不替谁下结论。空串 / 重复项由调用方在入口处清掉(草稿与分享两条入口本来就各自清了)。
|
||||
*/
|
||||
fun imageSendPlan(
|
||||
text: String,
|
||||
images: List<String>,
|
||||
documents: List<DraftDocument>,
|
||||
): ImageSendPlan {
|
||||
val kept = images.take(ShareRules.MAX_IMAGES)
|
||||
val messages = if (kept.isEmpty()) {
|
||||
listOf(PlannedMessage(text = text, imageUri = null, documents = documents))
|
||||
} else {
|
||||
kept.mapIndexed { index, uri ->
|
||||
PlannedMessage(
|
||||
text = if (index == 0) text else "",
|
||||
imageUri = uri,
|
||||
documents = if (index == 0) documents else emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
return ImageSendPlan(
|
||||
messages = messages,
|
||||
truncatedImages = images.size - kept.size,
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,239 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import top.yeij.cyrene.data.repository.MESSAGE_EDIT_MAX_RUNES
|
||||
import top.yeij.cyrene.data.repository.runeCount
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 一条消息上的动作菜单(长按气泡 / 点气泡旁那枚「⋯」都会打开它)。
|
||||
*
|
||||
* 用 Compose 的 `Dialog` + Miuix 内容,与抽屉里的新建 / 重命名 / 删除确认**同一套做法**:
|
||||
* 项目里没有 Miuix 的菜单类组件,而「凭空去猜一个没见过的弹层 API」正是 `SessionDrawerContent`
|
||||
* 当初刻意避开的做法。选项行仍是 `PreferenceCard { BasicComponent(...) }`
|
||||
* (Miuix 列表项自身没有背景与圆角)。
|
||||
*
|
||||
* 两个动作的可见性由调用方按**同一份判据**给出(`canEditMessage` / `canDeleteMessage`):
|
||||
* 编辑只对 user 消息出现,删除对 user 与 assistant 都出现。两条都不成立时这个弹层不会被打开。
|
||||
*
|
||||
* 每一行都带一句 summary 说明会发生什么——尤其是「其它端会跟着变」和「不可恢复」这两件
|
||||
* 用户看不见的后果。
|
||||
*/
|
||||
@Composable
|
||||
fun MessageActionMenu(
|
||||
editable: Boolean,
|
||||
deletable: Boolean,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
|
||||
.padding(DIALOG_PADDING),
|
||||
) {
|
||||
Text("这条消息", style = MiuixTheme.textStyles.title3)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (editable) {
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
title = "编辑",
|
||||
summary = "改这条消息的内容,其它端会跟着刷新",
|
||||
onClick = onEdit,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if (deletable) {
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
title = "删除",
|
||||
summary = "从所有端删除这条消息,不可恢复",
|
||||
onClick = onDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton("取消", onClick = onDismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑弹层:一个多行输入框 + 码点计数 + 取消 / 保存。
|
||||
*
|
||||
* ## 编辑框里是纯文本,但绝不静默丢东西
|
||||
* 初值是 `domain/chat/MessageEditText.kt` 的 `editableMessageOf` 算出来的**纯文本**:后端的正文里
|
||||
* 可能有 Web 渲染链路的标记(`<p>` / `<action>` / `<narration>`),它们在气泡里被 `domain/parse`
|
||||
* 解析成正文与旁白——让用户对着尖括号改字不是「改这条消息」该有的样子。
|
||||
* 原文本来就是纯文本时**逐字节不变**(不 trim、不折叠空行),所以「剥标记」这条路径只在真有标记时才生效。
|
||||
*
|
||||
* 剥标记是有代价的:保存回去的是这段纯文本,原来的旁白/动作就没了。所以这件事**必须明说**——
|
||||
* [MessageActionState.Edit.markupReplaced] 为 true 时,输入框下面会多一行小字,把「保存后这条消息
|
||||
* 只剩你写的这段文字、原来的旁白与动作会被替换掉」讲清楚;为 false(原文没有标记)时**一行都不多显示**。
|
||||
*
|
||||
* 反过来,保存时也**不**把标记猜着装回去:任何回填都要先猜「旁白/动作该插回哪一句之间」,
|
||||
* 猜错就是静默移动她说过的话,且用户看不出发生了什么。提交的就是用户写的这段字本身
|
||||
* ([ChatViewModel.submitEdit]),不猜是刻意的。
|
||||
*
|
||||
* ## 计数为什么按码点
|
||||
* 服务端上限是 4000 **rune**([MESSAGE_EDIT_MAX_RUNES]),所以这里也用 [runeCount]:
|
||||
* 按 `length` 数会把 emoji 算成两个,边界上既会误伤也会误放。超限时保存键直接不可点,
|
||||
* 免得用户点了才知道——服务端那 400 一样会如实说出来,但能提前避免就提前避免。
|
||||
*
|
||||
* ## 失败留在弹层里
|
||||
* [MessageActionState.Edit.error] 非空时显示在按钮上方:这条消息原地重试即可,输入不丢。
|
||||
* (弹层被关掉之后才失败的,落到页面顶部那行横幅,见 [ChatUiState.applyMessageActionFailure]。)
|
||||
*
|
||||
* @param state 弹层的草稿(纯文本)/ 原文带不带标记 / 进行态 / 失败原因(状态在 VM 里,
|
||||
* 弹层自己不持有提交结果)。
|
||||
*/
|
||||
@Composable
|
||||
fun MessageEditDialog(
|
||||
state: MessageActionState.Edit,
|
||||
onDraftChange: (String) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val used = runeCount(state.draft)
|
||||
val tooLong = used > MESSAGE_EDIT_MAX_RUNES
|
||||
// 空白内容服务端必拒(400),本端不发这一趟;在飞时也不许再点一次(没有幂等键)。
|
||||
val canSave = state.draft.isNotBlank() && !tooLong && !state.inFlight
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
|
||||
.padding(DIALOG_PADDING),
|
||||
) {
|
||||
Text("编辑这条消息", style = MiuixTheme.textStyles.title3)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextField(
|
||||
value = state.draft,
|
||||
onValueChange = onDraftChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = "消息内容",
|
||||
useLabelAsPlaceholder = true,
|
||||
maxLines = 8,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "$used / $MESSAGE_EDIT_MAX_RUNES",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = if (tooLong) {
|
||||
MiuixTheme.colorScheme.error
|
||||
} else {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
},
|
||||
)
|
||||
if (state.markupReplaced) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// 明说「保存会替换掉什么」:原文的旁白/动作标记不会再出现在这条消息里。
|
||||
Text(
|
||||
text = "原文带旁白/动作标记:保存后这条消息只剩你写的这段文字,原来的旁白与动作会被替换掉。",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
if (state.error != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = state.error,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton("取消", onClick = onDismiss)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(
|
||||
text = if (state.inFlight) "保存中…" else "保存",
|
||||
onClick = onSubmit,
|
||||
enabled = canSave,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除的二次确认弹层。
|
||||
*
|
||||
* 与抽屉删会话那一处同形(`ConfirmDialog` 的做法):`Dialog` + Miuix 内容 +
|
||||
* 标题 / 说明 / 取消 + 确认。这里的说明必须把两件事说清:**影响所有端**、**不可恢复**——
|
||||
* 删除是唯一一个没有回头路的动作。
|
||||
*
|
||||
* [MessageActionState.Delete.error] 非空时显示在按钮上方(例如别的端已经先删掉了,回 404):
|
||||
* 用户看到的是一句能解释原因的话,而不是一个点了没反应的按钮。
|
||||
*/
|
||||
@Composable
|
||||
fun MessageDeleteDialog(
|
||||
state: MessageActionState.Delete,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
|
||||
.padding(DIALOG_PADDING),
|
||||
) {
|
||||
Text("删除这条消息?", style = MiuixTheme.textStyles.title3)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "它会从所有端移除,且不可恢复。",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
if (state.error != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = state.error,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton("取消", onClick = onDismiss)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(
|
||||
text = if (state.inFlight) "删除中…" else "删除",
|
||||
onClick = onConfirm,
|
||||
enabled = !state.inFlight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 三个弹层共用的内边距(与 `NewSessionDialog` / 重命名 / 删除确认一致):四周 20dp。
|
||||
* 抽成一处是为了「改一次都跟着改」——它们本来就该长得一样。
|
||||
*/
|
||||
private val DIALOG_PADDING = 20.dp
|
||||
@@ -0,0 +1,43 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.repository.canDeleteMessage
|
||||
import top.yeij.cyrene.data.repository.canEditMessage
|
||||
|
||||
/**
|
||||
* 这条消息在**服务端**有没有对应的一行(纯函数)。
|
||||
*
|
||||
* 判据只有一条:`id` 带不带 [PROACTIVE_LOCAL_ID_PREFIX]。那个前缀是本端给**降级气泡**
|
||||
* (`proactive_message` 落库失败那一档,见 [ProactiveIngest.Bubble])自铸的,服务端的 id
|
||||
* 永远不会长这样([proactiveIngestFor] 的 KDoc 说明了它是怎么来的)。
|
||||
*
|
||||
* 为什么这件事要单独成判据:一条消息「在服务端存在」是**打服务端那类动作的前提**。
|
||||
* 拿一个服务端没见过的 id 去 `PATCH` / `DELETE`,最好的结果是 404,其余是一句用户看不懂的报错——
|
||||
* 而这一切仅仅因为他长按了一下一条屏幕上看起来很正常的消息。
|
||||
*
|
||||
* **空 `id` 不在本判据的处理范围内**(今天的行为逐字不变):那属于「形状漂移」的坏帧,
|
||||
* 是另一件事,不在本轮收口的范围里。
|
||||
*/
|
||||
fun isServerBackedMessage(message: HistoryMessageDto): Boolean =
|
||||
!message.id.startsWith(PROACTIVE_LOCAL_ID_PREFIX)
|
||||
|
||||
/**
|
||||
* 这条消息**能不能编辑**——在角色口径([canEditMessage])之上再叠一道「它在服务端存在吗」。
|
||||
*
|
||||
* 两道合起来才是界面与 VM 该用的判据:角色那一道回答「产品上允不允许改她说过的话」,
|
||||
* 本判据回答「这一趟请求有没有意义」。任一道不成立都是**入口不出现**(fail-closed)。
|
||||
*
|
||||
* **只有会打服务端的动作走这里**:气泡里的文本选择 / 复制是纯本端的事
|
||||
* (`SelectionContainer`,与动作菜单是两条独立的路),不归这两个函数管,也不该被它们挡住。
|
||||
*/
|
||||
fun canEditMessageOnServer(message: HistoryMessageDto): Boolean =
|
||||
isServerBackedMessage(message) && canEditMessage(message)
|
||||
|
||||
/**
|
||||
* 这条消息**能不能删除**——在角色口径([canDeleteMessage])之上再叠一道「它在服务端存在吗」。
|
||||
*
|
||||
* 降级气泡恰恰是个必须被挡住的例子:它是 `assistant` 行 ⇒ [canDeleteMessage] 会说「能删」,
|
||||
* 但服务端根本没有这一行,删它只会用本地 id 打出一趟注定失败的请求(见 [isServerBackedMessage])。
|
||||
*/
|
||||
fun canDeleteMessageOnServer(message: HistoryMessageDto): Boolean =
|
||||
isServerBackedMessage(message) && canDeleteMessage(message)
|
||||
@@ -3,6 +3,49 @@ package top.yeij.cyrene.ui.chat
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.domain.enums.EnumCatalog
|
||||
|
||||
/**
|
||||
* 手打消息的幂等键前缀:本端铸出来的键形如 `local-<uuid>`。
|
||||
*
|
||||
* 铸键处与判据处共用这一个常量([ChatViewModel] 铸、[isSharedClientMsgId] 判),改一处即两处同步。
|
||||
*/
|
||||
const val LOCAL_CLIENT_MSG_ID_PREFIX = "local-"
|
||||
|
||||
/**
|
||||
* 分享进来的消息的幂等键前缀:`share-<uuid>`。
|
||||
*
|
||||
* 加前缀**不破坏唯一性**——后面那截仍是随机 UUID,而 `clientMsgId` 本来就只要求「同一会话内非空且唯一」
|
||||
* (契约 v0.56 §3.13),前缀只是把「这条是怎么进来的」顺带写进了这个已经存在的字段。
|
||||
*/
|
||||
const val SHARE_CLIENT_MSG_ID_PREFIX = "share-"
|
||||
|
||||
/**
|
||||
* 一串 `clientMsgId` 是不是分享进来的那条。
|
||||
*
|
||||
* 判据**只有前缀**这一个来源:`clientMsgId` 由服务端**原样回填**(见 `HistoryMessageDto.clientMsgId`),
|
||||
* 于是「怎么进来的」这件事能从幂等键上读回来,不必往用户正文里拼任何标记——正文里加标记是
|
||||
* 与后端定过的红线(`ShareRules` 的「为什么正文一个字都不加」/ `02` 的 F4)。
|
||||
*
|
||||
* 边界取值(都按「宁可少画一枚角标,也不冒充事实」定):
|
||||
* - `null` / 空串 / 纯空白 ⇒ false:没有键就是没有证据,不猜;
|
||||
* - `local-…`(手打的那条路)⇒ false;
|
||||
* - 恰好 `"share-"`(前缀后面没有尾巴)⇒ **false**:本端铸键永远是 `share-<uuid>`,
|
||||
* 尾巴为空的键不可能是本端铸出来的,给它画角标等于替一条来路不明的消息声明「这是你分享的」;
|
||||
* - 大小写敏感、不做 trim:键是机器生成的字面量、服务端原样回填,不是给人读的输入。
|
||||
*/
|
||||
fun isSharedClientMsgId(clientMsgId: String?): Boolean =
|
||||
clientMsgId?.startsWith(SHARE_CLIENT_MSG_ID_PREFIX) == true &&
|
||||
clientMsgId.removePrefix(SHARE_CLIENT_MSG_ID_PREFIX).isNotBlank()
|
||||
|
||||
/**
|
||||
* 这条消息是不是本端「分享进来」的那条(气泡角标的判据,前缀规则见 [isSharedClientMsgId])。
|
||||
*
|
||||
* 在判据之上多一道 role 闸:这枚角标说的是「**你**把外面的东西分享给了她」,只有用户侧的气泡才可能
|
||||
* 成立。助手那一边不会有这种键,但闸摆在这里更好——万一日后有一行的 role 与键对不上,
|
||||
* 宁可少画一枚角标,也不要在她的气泡上写「用户分享」。
|
||||
*/
|
||||
fun isSharedMessage(message: HistoryMessageDto): Boolean =
|
||||
message.role == "user" && isSharedClientMsgId(message.clientMsgId)
|
||||
|
||||
/**
|
||||
* 来源角标(S1.3):每条消息默认显示来源。
|
||||
* - 主人发的 → 「来自 X」(从哪一端发出)
|
||||
|
||||
@@ -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,121 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
|
||||
/**
|
||||
* 一帧 `proactive_message`(她主动说的话)**该怎么进本端**(纯函数 [proactiveIngestFor] 的结果)。
|
||||
*
|
||||
* 独立成一个类型而不是「返回一条 `HistoryMessageDto?`」,是因为两条并入路**外观相同、真相不同**:
|
||||
* [Persisted] 那条是服务端历史里的行(按 id upsert,刷新后仍在),[Bubble] 那条只活在本端内存里。
|
||||
* 上层要能对它们做不同的说明,测试也要能分别钉住。
|
||||
*/
|
||||
sealed interface ProactiveIngest {
|
||||
|
||||
/**
|
||||
* **不并入本 VM**:这一帧不是给这个会话的(见 [proactiveIngestFor] 的归属判据),
|
||||
* 或帧里没有任何可显示的东西。
|
||||
*/
|
||||
data object Skip : ProactiveIngest
|
||||
|
||||
/**
|
||||
* **并入一条已落库的 assistant 行**(帧带落库行的权威 id)。
|
||||
*
|
||||
* 这就是 v0.72 之后她这句话的真相:它既是实时帧,也是主会话历史里的一行。走 [Persisted]
|
||||
* 而不是画一个气泡,客户端才有「刷新 / 重开 / 切会话拉历史之后她的话还在」这条性质——
|
||||
* 而重复也**不会**发生:并入走的是以 [HistoryMessageDto.id] 为键的 upsert
|
||||
* (`mergeMessages`),同一条(实时帧 + 握手回放 + 分页)无论到几次都只有一行。
|
||||
*/
|
||||
data class Persisted(val message: HistoryMessageDto) : ProactiveIngest
|
||||
|
||||
/**
|
||||
* **降级路径**:帧**不带** `id`(服务端那一步落库失败了,它自己就说了「这条没有历史行」)
|
||||
* ⇒ 画一条只活在本端内存里的气泡,也好过什么都不显示。
|
||||
*
|
||||
* ⚠️ 它的代价必须说清:这条气泡**不在**服务端历史里,所以**刷新 / 重开 App / 切走再回来
|
||||
* 都不会再有它**(那些时刻显示的是服务端的历史,而服务端没有这一行)。这是「如实显示一条
|
||||
* 可能留不住的记录」而不是「假装它已落库」——正因如此它才与 [Persisted] 分开。
|
||||
*/
|
||||
data class Bubble(val message: HistoryMessageDto) : ProactiveIngest
|
||||
}
|
||||
|
||||
/**
|
||||
* 本端给降级气泡铸的 id 前缀。
|
||||
*
|
||||
* 一眼可辨(日志 / 截图上能认出这是本地行,不是服务端 id),也**永不**与服务端的 id 撞。
|
||||
*/
|
||||
internal const val PROACTIVE_LOCAL_ID_PREFIX = "proactive-local:"
|
||||
|
||||
/**
|
||||
* 一帧 `proactive_message` 该不该并进 [sessionId] 这个 VM、以什么身份并入(纯函数,判据可单测)。
|
||||
*
|
||||
* ## 1. 归属:**只进主会话**(这一步不能省)
|
||||
* 这一帧的帧壳**不带** `sessionId`(服务端构造时没设它,且 `ws_hub.go` 那个键是 `omitempty`),
|
||||
* 而 [top.yeij.cyrene.data.ws.isForSession] 对空 `sessionId` 的口径是**放行**(空 = 无法判定,
|
||||
* 不是「不属于本会话」)⇒ 同一批帧会投给**每一个**活着的 `ChatViewModel`。归属因此只能在这里
|
||||
* 自己钉死:她主动说的话落的是**主会话**(服务端 `history.MainSessionID` = 客户端的
|
||||
* [MAIN_SESSION_ID])。不钉就会**在每个次级会话里都冒出一条她的话**——比「看不到」更糟。
|
||||
*
|
||||
* 比较是**等值**、不是前缀:主会话 `web` 与次级会话 `web:xxx` 是两个会话。
|
||||
*
|
||||
* ## 2. 身份:有 `id` 就是已落库行,没有就是本地气泡
|
||||
* 服务端**先落库、再发帧**,并把落库行的 `id` / `createdAt` 放进载荷;落库失败时这两个键
|
||||
* 一起缺席。所以 `id` 的有无就是「这条在历史里存不存在」的判据,不需要别的猜测。
|
||||
*
|
||||
* ## 3. `createdAt` 的退化
|
||||
* `id` 在、`createdAt` 却缺席或非正数(形状漂移)时用 [nowMillis]:帧是刚刚到的,按「现在」
|
||||
* 排在最末就是它该在的位置;而服务端那一行是真的存在,下一次拉历史会用同一 id 把它连同真正的
|
||||
* 时间戳一起覆盖回来(`mergeMessages` 的 incoming 胜出)。
|
||||
*
|
||||
* ## 4. 无 `id` 且无正文 ⇒ [ProactiveIngest.Skip]
|
||||
* 这种帧里没有任何可显示的东西(落库失败的空帧),画一条空气泡只会让人以为出了别的事。
|
||||
*
|
||||
* 时钟与本地 id 都由调用方喂(判据自己不读表、不自铸随机数),所以这里可单测、可复现。
|
||||
*/
|
||||
fun proactiveIngestFor(
|
||||
event: WsEvent.ProactiveMessage,
|
||||
sessionId: String,
|
||||
nowMillis: Long,
|
||||
): ProactiveIngest {
|
||||
if (sessionId != MAIN_SESSION_ID) return ProactiveIngest.Skip
|
||||
|
||||
val content = event.content
|
||||
val id = event.id?.takeIf { it.isNotBlank() }
|
||||
val createdAt = event.createdAt?.takeIf { it > 0 }
|
||||
|
||||
if (id != null) {
|
||||
return ProactiveIngest.Persisted(
|
||||
HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = MAIN_SESSION_ID,
|
||||
role = ASSISTANT_ROLE,
|
||||
content = content,
|
||||
platform = event.platform?.takeIf { it.isNotBlank() },
|
||||
createdAt = createdAt ?: nowMillis,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (content.isBlank()) return ProactiveIngest.Skip
|
||||
|
||||
// 本地 id 用**帧里已有的稳定键**(IM 侧的消息号,其次是它的时间字面量)而不是随机数:
|
||||
// 同一个键第二次到达就是一次 upsert(仍只有一条),而且日志 / 截图上能追回它是哪一帧来的。
|
||||
// 随机 id 做不到这两件事——同样的帧再投一次(服务端重推 / 客户端重放)就会多出一个一模一样的气泡。
|
||||
val localKey = event.messageId?.takeIf { it.isNotBlank() }
|
||||
?: event.timestamp?.takeIf { it.isNotBlank() }
|
||||
?: nowMillis.toString()
|
||||
|
||||
return ProactiveIngest.Bubble(
|
||||
HistoryMessageDto(
|
||||
id = PROACTIVE_LOCAL_ID_PREFIX + localKey,
|
||||
sessionId = MAIN_SESSION_ID,
|
||||
role = ASSISTANT_ROLE,
|
||||
content = content,
|
||||
platform = event.platform?.takeIf { it.isNotBlank() },
|
||||
createdAt = createdAt ?: nowMillis,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** 她主动说的话恒是 assistant(服务端 `persistProactiveToMain` 定死)。 */
|
||||
private const val ASSISTANT_ROLE = "assistant"
|
||||
@@ -0,0 +1,88 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
/**
|
||||
* 本会话的**首屏**(`GET /messages` 最新一页,[ChatViewModel.start] 那一次)走到哪儿了。
|
||||
*
|
||||
* 它存在的唯一理由是让「重连时要不要再拉一次首屏」这件事**判得出来**,而这里必须能分辨
|
||||
* **三个**状态、不能只用一个「有没有数据」的位:
|
||||
*
|
||||
* - [Loading]:首屏那次**还在飞**。这一刻再发一次同样的请求就是白拉一次,更要紧的是它会把
|
||||
* 「首屏还没回来」这件事伪造成「首屏失败了」——而那正好是 W49 / S3 那条窄窗口的时序,
|
||||
* **本轮一个字节都不改**(见 [reconnectPull] 第 3 条)。
|
||||
* - [Loaded]:成功过。哪怕服务端真的**就是**没有消息(`hasMore=false`、列表空),也是成功过——
|
||||
* 所以「空会话」不该被读成「没拉到」(这正是「用有没有数据当判据」会踩的坑:每个空次级会话
|
||||
* 都会在每次重连被整表重拉一次,而那正是 S4/S7 这一类问题的来源)。
|
||||
* - [Failed]:确实没拿到(离线冷启动、超时、服务端错)。**只有这一档**才值得在连接恢复时补。
|
||||
*/
|
||||
internal enum class FirstPageState {
|
||||
Loading,
|
||||
Loaded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/** 连接恢复那一刻这一轮该做什么([reconnectPull] 的取值)。 */
|
||||
internal enum class ReconnectPull {
|
||||
|
||||
/**
|
||||
* 既有行为,一字未动:用掉线那一刻记下的 [ChatViewModel.gapCursor] 做一次 `after` 正向增量。
|
||||
*/
|
||||
Forward,
|
||||
|
||||
/**
|
||||
* **本轮新增**:本端首屏从来没成功过,而这是**次级会话** —— 拉一次最新一页。
|
||||
*
|
||||
* 它补的就是 S2 登记的那个洞:进次级会话时 WS 未连(冷启动离线)⇒ 首屏那次 REST 也失败
|
||||
* ⇒ 此后连接恢复**没有任何人补拉**,屏幕长期空着「还没有消息」,直到用户手动切走再回来。
|
||||
*/
|
||||
FirstPage,
|
||||
|
||||
/** 什么都不做。 */
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接刚刚恢复(`WsState.Connected`)时这一轮该做什么(**纯函数**,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么次级会话需要它,而主会话不需要
|
||||
* 主会话有**两条**安全网:握手回放 `history`(最近 50 条,服务端固定回放主会话,
|
||||
* `ws_hub_conn.go` 写死 `replayHistory(MainSessionID)`)与 `after` 正向增量。次级会话**只有后者**
|
||||
* ——`history` 帧里根本没有它的消息(`isForSession` 会把它们整帧滤掉)。于是「首屏没拿到 +
|
||||
* 之后连接恢复」这个组合下,主会话被回放兜住,次级会话什么都没有。
|
||||
*
|
||||
* ⚠️ 所以这里的修法**不是**照抄主会话那条(给次级会话要 `history` 是改服务端的契约面),而是走
|
||||
* **本项目自己那条 REST 补拉**:连接恢复时,如果本端首屏从来没成功过,就自己去拉一次最新一页。
|
||||
*
|
||||
* ## 判据与优先级(自上而下,先命中先返回)
|
||||
* 1. [sessionKnownGone] ⇒ [ReconnectPull.Nothing]。**注定 404 的补拉一个都不发**(S7 ①):
|
||||
* 这一位由两个来源合成——本端自己删过它(`GoneSessions`),或已经有了 `404 session_not_found`
|
||||
* 的结论([ChatUiState.sessionGone])。前者让「每次重连一次注定失败的往返」变成零次;后者让
|
||||
* 「发现之后还在反复撞」收敛成**恰好一次**。
|
||||
* 2. `gapCursor != null` ⇒ [ReconnectPull.Forward]:既有行为,**一个字都不改**。有一个断线窗口
|
||||
* 要补时,`after` 就是对的(它比首屏更精确:只补那个洞)。也正因为这一档在前面,
|
||||
* 「本轮新增的那条路」只在今天**什么都不做**的那一刻才会走。
|
||||
* 3. 首屏 [FirstPageState.Failed] **且**不是主会话 ⇒ [ReconnectPull.FirstPage]。
|
||||
* - **Failed 才算**:`Loading` 时刻再拉一次会与首屏那次抢跑(并且会把 S3 那条「history 帧一到就
|
||||
* `loading=false`」的时序改掉);`Loaded` 之后再拉就是「每次重连整表重拉」,正是 S4/S7 的成因。
|
||||
* - **主会话除外**:它由握手 `history` 兜住(见上),在这里再补一条 REST 只会变成**第二套**
|
||||
* 补口径——两套判据迟早分叉,而分叉的那一侧就是漏出去的那一侧。主会话的兜底行为因此
|
||||
* **逐字不变**。
|
||||
* 4. 其余 ⇒ [ReconnectPull.Nothing]。
|
||||
*
|
||||
* 判据里除了这四样什么都不看(读不到 VM 状态、不读表),才能穷举、才能在 JVM 上单测
|
||||
* (先例:`ui.sessions.silentRefreshStep`、`ui.chat.shareLanding`)。
|
||||
*
|
||||
* @param sessionId 本 VM 的会话(比较是**等值**:主会话 `web` 与次级 `web:xxx` 是两个会话)。
|
||||
* @param gapCursor 掉线那一刻记下的 `after` 游标;`null` = 没有需要补的洞。
|
||||
* @param sessionKnownGone 本端已知「服务端没有这个会话了」——本地删过,或者已经 404 过。
|
||||
*/
|
||||
internal fun reconnectPull(
|
||||
sessionId: String,
|
||||
firstPage: FirstPageState,
|
||||
gapCursor: Long?,
|
||||
sessionKnownGone: Boolean,
|
||||
): ReconnectPull = when {
|
||||
sessionKnownGone -> ReconnectPull.Nothing
|
||||
gapCursor != null -> ReconnectPull.Forward
|
||||
firstPage == FirstPageState.Failed && sessionId != MAIN_SESSION_ID -> ReconnectPull.FirstPage
|
||||
else -> ReconnectPull.Nothing
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.repository.ChatAck
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
|
||||
/**
|
||||
* 回落 REST 那一次投递的**整通上限**(毫秒)——`POST /api/v1/chat` 从发出到拿到回执,连接、写、
|
||||
* 读全算在内。
|
||||
*
|
||||
* ## 一个 client、两个超时(W45)
|
||||
* 它和 client 上的 [top.yeij.cyrene.data.api.CONNECT_TIMEOUT_MILLIS] 是**两个分开的闸**,管的是
|
||||
* 两件不同的事:
|
||||
* - **连接超时(2.5 秒,挂在 client 上)**管「连不上」:完全无网、死网、对端不在听时 2.5 秒内
|
||||
* 失败 ⇒ 进队列。它是「无网快速进队列」这条性质的**全部**依据;
|
||||
* - **本值(10 秒,挂在这一次投递上)**管「连上了但一直不回话」:半死的代理、接住连接却不回应
|
||||
* 的服务端,到点按网络类失败处理 ⇒ 进队列。
|
||||
*
|
||||
* 本值是 W41 就写在纸上的那一个,**不许拉长**:它直接是用户按完发送键之后的干等时间上界。正常
|
||||
* 网络下这一次本该毫秒级拿到回执(「服务端收下了」的判据见 [restAttemptLanding]),10 秒是
|
||||
* 「回得慢」与「这条路已经死了」之间的分界。
|
||||
*
|
||||
* ## 为什么它不写成 client 的 `callTimeout`
|
||||
* 这个 client 是**共用**的:长文本、内联图片那两条本来就该走 REST 的路用的是同一个
|
||||
* ([ChatViewModel.sendViaRest])。client 级 `callTimeout` 会把它们一并框住——一条慢但合法的
|
||||
* 请求会被判成失败,那是给一条本轮不打算碰的路引入新失败面。放在调用点则「哪一次投递有整通上限」
|
||||
* 是显式的,就写在 [ChatViewModel.sendViaRestFallback] 那一行上。
|
||||
*
|
||||
* 超时**不当作「服务端拒绝」**:那条消息可能已经被服务端收下(只是回执没回来)⇒ 按网络类失败
|
||||
* 处理 ⇒ 进队列,重发时仍用同一个 `clientMsgId`,服务端按 `(session_id, client_msg_id)` 判重后
|
||||
* 回 `chat_duplicate` / `{duplicate:true}`,不会落成第二条消息。
|
||||
*/
|
||||
const val REST_FALLBACK_TIMEOUT_MILLIS = 10_000L
|
||||
|
||||
/**
|
||||
* 一条消息这一刻该怎么走([sendRouteFor] 的取值)。
|
||||
*
|
||||
* 与 [top.yeij.cyrene.domain.chat.SendChannel] **不是一回事**,两者正交、同时存在:
|
||||
* - [top.yeij.cyrene.domain.chat.SendChannel] 判的是「**这条内容**该走哪条上行通道」——静态的、
|
||||
* 只看文本字节数 / 有没有图片 / 有没有附件,与网络状况无关([top.yeij.cyrene.domain.chat.ChatSendRouting]);
|
||||
* - 本枚举判的是「**这一刻这个动作**该怎么做」——动态的、只看「帧交出去了没有」与连接状态。
|
||||
*
|
||||
* 阶梯的**第一步**永远是 `SendChannel` 那次判定(有图 / 有附件的消息根本进不到下面这两档,
|
||||
* 它们本来就是 REST);本枚举只在「内容是短文本、本该走 WS」那条路上生效。
|
||||
*/
|
||||
enum class SendRoute {
|
||||
/** 帧已经交给 socket(或连接就绪、就该走 WS):照今天的样子继续等回执。 */
|
||||
Ws,
|
||||
|
||||
/**
|
||||
* WS 没把帧交出去、而连接不是 [WsState.Offline] ⇒ **直接试 REST** 把这条发出去
|
||||
* (短文本也走 REST:`POST /api/v1/chat` 从第一天起收的就是同一种消息)。
|
||||
*
|
||||
* 这里**没有任何探测**(W45 删掉了 W41 那道 `GET /api/v1/health`):真正的 REST 请求本身就
|
||||
* 是最直接的检验——试成了这条还顺手发出去了,试败了才进队列。理由见 [sendRouteFor]。
|
||||
*/
|
||||
Rest,
|
||||
|
||||
/** 连接是 [WsState.Offline] ⇒ **不试 REST**,直接进补发队列等连接。 */
|
||||
Queue,
|
||||
}
|
||||
|
||||
/**
|
||||
* [sendRouteFor] 那条判据**依据的是哪一条**([sendRouteDecisionFor] 的取值,W46)。
|
||||
*
|
||||
* 为什么把「原因」也变成返回值:结果只有三档,而「为什么是这一档」在真机上是**看不见的**。W45
|
||||
* 那轮回归就卡在这儿——界面上那条消息进了队列(`queuedForConnection`)、网络侧一条请求都没有,
|
||||
* 而「零请求 + 进队列」在判据里只可能来自 [SendRoute.Queue];可顶栏同时显示「重连中…」。事后只看
|
||||
* 结果与顶栏,两种可能(判据读到 [WsState.Offline] vs 判据读了另一个值)**在外同形**。于是这一行
|
||||
* 日志把依据本身也打出来(tag 见 `ChatViewModel.SEND_ROUTE_LOG_TAG`),矛盾就能当场分辨。
|
||||
*
|
||||
* 三档**与 [sendRouteFor] 的三条规则一一对应**,没有第四种可能:它是那条 `when` 的分支标识,
|
||||
* 不是另算一遍的近似值([sendRouteFor] 就是本函数的 `.route`)。
|
||||
*/
|
||||
enum class SendRouteReason {
|
||||
/** 规则 1:`wsDispatched` ⇒ 帧已经交给 socket,连接状态不参与判断。 */
|
||||
FrameDispatched,
|
||||
|
||||
/** 规则 2:连接是 [WsState.Offline](会话已知是死的)⇒ 进队列,**一个请求都不发**。 */
|
||||
ConnectionOffline,
|
||||
|
||||
/** 规则 3:其余未就绪档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])⇒ 直接试 REST。 */
|
||||
ConnectionNotReady,
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次路由判定的**结果 + 依据**([sendRouteDecisionFor] 的取值)。
|
||||
*
|
||||
* 两者必须同出一次:分开问两次会把「判据看到的事实」与「结论」割开,而那正是排查时最容易错的地方。
|
||||
*/
|
||||
data class SendRouteDecision(val route: SendRoute, val reason: SendRouteReason)
|
||||
|
||||
/**
|
||||
* 同 [sendRouteFor],但把「依据」一并带出来([SendRouteReason],W46 可观测性)。
|
||||
*
|
||||
* 判据、优先级、行为**逐字未改**:下面这个 `when` 就是 [sendRouteFor] 原来那一个,只是每一支把
|
||||
* 自己标了出来。[sendRouteFor] 现在读它的 [SendRouteDecision.route],所以两处不可能分叉。
|
||||
*/
|
||||
fun sendRouteDecisionFor(wsDispatched: Boolean, connection: WsState): SendRouteDecision = when {
|
||||
wsDispatched -> SendRouteDecision(SendRoute.Ws, SendRouteReason.FrameDispatched)
|
||||
connection == WsState.Offline -> SendRouteDecision(SendRoute.Queue, SendRouteReason.ConnectionOffline)
|
||||
else -> SendRouteDecision(SendRoute.Rest, SendRouteReason.ConnectionNotReady)
|
||||
}
|
||||
|
||||
/**
|
||||
* 回落 REST 宣告失败之后的落点([restFailureLanding] 的取值)。
|
||||
*/
|
||||
enum class RestFailureLanding {
|
||||
/** 这一条**还没上过路**(服务端没给过任何裁决)⇒ 进队列,连接一好就补发。 */
|
||||
Queue,
|
||||
|
||||
/** 服务端**给过裁决**(拒绝 / 未授权 / 协议对不上)⇒ 走今天那条回滚 + 文案。 */
|
||||
Rollback,
|
||||
}
|
||||
|
||||
/**
|
||||
* 「这条 `chat` 帧交出去了没有 + 这一刻的连接状态」⇒ 该走哪条路(**纯函数**,VM 与测试共用)。
|
||||
*
|
||||
* 判据里**没有任何探测输入**(W45):路由只看这两个事实,`GET /api/v1/health` 那一层连同它的
|
||||
* 客户端、独立超时预算与失败面一起删掉了。
|
||||
*
|
||||
* ## 为什么是「直接试 REST」(W45 主人的定案)
|
||||
* W41 的阶梯是「WS 未就绪 ⇒ **先探 `GET /api/v1/health`** ⇒ 通才回落 REST」。真机回归
|
||||
* (`devtools/ws-block-proxy/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息**进了队列而不是回落
|
||||
* REST**;而 `ProbeClientTest` 证明那条链在 JVM 上是通的,`Unreachable` 生在**连接之前**,三种
|
||||
* 成因(建 client 抛 / 连上但一个字没写 / ConnectException)**在外同形** ⇒ 那一轮无法定论。
|
||||
*
|
||||
* 结论是**不该有探测**:探测只是「能不能连上」的**替身**,而真正的 REST 请求本身就是最直接的
|
||||
* 检验——试成了消息还顺手发出去了,试败了进队列(内容一个字节不丢,重发仍是同一个
|
||||
* `clientMsgId`)。探测唯一省下的是「完全无网时少发一个注定失败的请求」,代价却是一个独立
|
||||
* client、一套独立超时预算和一整套新的失败面;而那个探针 client **从不复用连接池**,每次都要现做
|
||||
* 一次冷握手,预算还比主链路紧 4 倍(2.5 秒整通 vs 10 秒连接)——今天这个 bug 的形态就长在
|
||||
* 这上面。
|
||||
*
|
||||
* 直接试也**不会**把「无网快速进队列」弄丢:那一条靠的是 client 上的短连接超时
|
||||
* ([top.yeij.cyrene.data.api.CONNECT_TIMEOUT_MILLIS],2.5 秒),不是探针。
|
||||
*
|
||||
* ## 判据与优先级
|
||||
* 1. `wsDispatched == true` ⇒ [SendRoute.Ws]。帧已经交给 socket,连接状态**不再参与**判断
|
||||
* (`sendChat` 判的就是自己的状态)。**成功路径零改动**:不打标记、不说告知、不写任何状态。
|
||||
* 2. 连接是 [WsState.Offline] ⇒ [SendRoute.Queue]。这一档的语义是「握手 401 且续期也失败」
|
||||
* (终态、不会自动重连),也就是说**会话已知是死的**;这时候去试 REST 只会拿到 401 并落到
|
||||
* 「回滚 + 一句会误导人的鉴权错误」,把 W40 那句「已排队不会丢」换成更差的结果。**保留 W41
|
||||
* 这个决定**:它当时的理由(健康端点不鉴权、必然回 200,于是判成「通」)随探测一起消失了,
|
||||
* 但结论换到直接试这边**一字不用改**——不鉴权的探针回 200 是「假通」,带令牌的 chat 端点回
|
||||
* 401 是**真不通**,两条路都指向同一件事:这一档不该发。
|
||||
* 3. 其余三档未就绪([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])
|
||||
* ⇒ [SendRoute.Rest]:**直接试 REST**,不等任何探测。
|
||||
*
|
||||
* 判据只认 `Connected` **一个值**(不是「不等于 Offline」之类的近似)——连接状态的另外四档
|
||||
* 都意味着帧没有被交给任何服务端。
|
||||
*
|
||||
* 本函数只回三个结果;**「为什么是这一档」在 [sendRouteDecisionFor]**(W46 加的可观测性,判据本身
|
||||
* 一字未动):真机上看不见「判据读到的是哪个值」,而那正是排查「发送为什么进了队列」时唯一要分的
|
||||
* 东西。
|
||||
*/
|
||||
fun sendRouteFor(
|
||||
wsDispatched: Boolean,
|
||||
connection: WsState,
|
||||
): SendRoute = sendRouteDecisionFor(wsDispatched, connection).route
|
||||
|
||||
/**
|
||||
* 这份回执照的是「**服务端已经收下这条**」(受理起回合 / 忙时续接排队 / 早就收过这条幂等键)。
|
||||
*
|
||||
* 只有这一族才算了结:被拒的那三种(队列满 / 接不住 / 明确拒绝)不算——那条消息没有上路,走
|
||||
* [ChatAck] 那六支里的回滚 + 文案。用 `when` 穷举而不是 `!=` 某个值:回执面将来多一档时,
|
||||
* 这里必须有人做决定。
|
||||
*
|
||||
* 它是[直接试 REST][restAttemptLanding] 那一步「**收下了没有**」的**唯一**判据。W45 把它从
|
||||
* `ChatViewModel` 的私有方法搬到这里:这条判据决定了一条消息会不会进补发队列(收下了就不该进),
|
||||
* 不该只活在 VM 里、只被组合着一个界面才测得到。
|
||||
*/
|
||||
fun ChatAck.isAccepted(): Boolean = when (this) {
|
||||
ChatAck.Accepted, is ChatAck.Queued, is ChatAck.Duplicate -> true
|
||||
ChatAck.QueueFull, is ChatAck.Unavailable, is ChatAck.Rejected -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次「直接试 REST」的落点([restAttemptLanding] 的取值)。
|
||||
*
|
||||
* 四档**穷举**「服务端有没有收下 / 有没有给过裁决」这两个问题的组合,每一档对应一个明确的处置,
|
||||
* 没有「默认继承前一档」的余地。判据决定的是**一条消息的去向**(进队列还是回滚),所以两档的
|
||||
* 代价不对称:误判成 [Accepted] 会让一条没上路的消息凭空消失,误判成 [Unsent] 只是多补发一次
|
||||
* (幂等键兜住,不落第二条)——拿不准一律往 [Unsent] 倒。
|
||||
*/
|
||||
sealed interface RestAttemptLanding {
|
||||
/**
|
||||
* **完成**:服务端收下了这条,[ack] 是它的回执(原样交给既有那六支)。
|
||||
*
|
||||
* 这一档**不进队列**:收下了还排队会白补发一次(幂等键虽然兜得住,但那是浪费,而且会让界面上
|
||||
* 那句「排队中 · 还没连上」变成假话)。
|
||||
*/
|
||||
data class Accepted(val ack: ChatAck) : RestAttemptLanding
|
||||
|
||||
/** **进队列**:这条还没上路(分档见 [restAttemptLanding]),等连接一好按同一个键补发。 */
|
||||
data object Unsent : RestAttemptLanding
|
||||
|
||||
/** **回滚**:服务端给过裁决(拒绝),回滚与文案都在既有那六支里([ack] 原样交给它们)。 */
|
||||
data class Refused(val ack: ChatAck) : RestAttemptLanding
|
||||
|
||||
/** **回滚**:本端异常(401 / 配置类 / 映射不出来的服务端错误),用 [error] 自己的文案。 */
|
||||
data class Errored(val error: CyreneError) : RestAttemptLanding
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一次「直接试 REST」的产物 ⇒ 落点(**纯函数**,VM 与测试共用)。
|
||||
*
|
||||
* ## 怎么判定「服务端收下了」(不需要等整轮生成完)
|
||||
* `POST /api/v1/chat` 在 `stream:true` 下的回执**不是**内容流,而是一个小 JSON:服务端把这一轮
|
||||
* 交给编排器之后就写回执返回(`Cyrene_v2/internal/services/gateway/chat_stream_dispatch.go:201`
|
||||
* 的 `writeChatStreamAck`:`200 {"stream":true}` / `202 {stream:true,queued:true}` /
|
||||
* `200 {duplicate:true}` / `409 chat_busy` / `503 chat_dispatch_failed`),真正的回合跑在它自己
|
||||
* 派生的 goroutine 上(同文件 `submitChatStream` 的注释:「streamChat 同步返回提交判定,因此这里
|
||||
* 不会阻塞在模型调用上」)。内容帧只走**已连接的 WS**(见 [ChatViewModel.sendViaRestFallback] 的
|
||||
* 「回复为什么收不到、又怎么补回来」一段)。
|
||||
*
|
||||
* 所以本端要判的只是「**回执到了没有、它说的是不是收下了**」:[ChatRepository] 拿到 HTTP 响应并
|
||||
* 把那个小 JSON 解析成 [ChatAck] 就算到了(`ChatApi.sendChat` 返回的是
|
||||
* `Response<ApiEnvelope<ChatAckDto>>`,Retrofit 读完这一小段 body 即返回),**不用**等任何生成。
|
||||
* 也就是说这次调用等的是「**服务端收下了**」这个事实,不是那一轮的产出。
|
||||
*
|
||||
* @param attempt `withTimeoutOrNull(REST_FALLBACK_TIMEOUT_MILLIS) { chatRepository.send(request) }`
|
||||
* 的**原样**产物:`null` = 整通上限到点(本端没等到任何回执);`Result.success` = 服务端回了
|
||||
* 回执(受理与拒绝都在里面,靠 [ChatAck.isAccepted] 分开);`Result.failure` = 本端异常
|
||||
* (网络 / 配置 / 401)。
|
||||
*
|
||||
* ## 判据与优先级
|
||||
* 1. `null`(整通上限到点)⇒ [RestAttemptLanding.Unsent]。本端**没等到任何裁决**:那条消息可能
|
||||
* 已经被服务端收下(只是回执没回来),也可能压根没到——本端分不出来,而**分不出来就往队列
|
||||
* 倒**(fail-closed)。重发还是同一个 `clientMsgId`,服务端若其实已经收下,只会回
|
||||
* `duplicate`(不会落成第二条)。
|
||||
* 2. `Result.success` 且 [ChatAck.isAccepted] ⇒ [RestAttemptLanding.Accepted]:**服务端收下了**,
|
||||
* 就是发出去了,**不进队列**。
|
||||
* 3. `Result.success` 且不被受理 ⇒ [RestAttemptLanding.Refused]:服务端**给过裁决**(`409` 队列满 /
|
||||
* `500` 接不住 / `503` 派发失败 / `400` 别的明确拒绝)⇒ 走今天那条回滚 + 文案,绝不偷偷重发。
|
||||
* 4. `Result.failure` ⇒ 交给 [restFailureLanding](**W41 那套口径一字未改**):网络族
|
||||
* (`NoNetwork` / `Dns` / `Timeout` / `ConnectFailed`)与「异常映射不出来」⇒
|
||||
* [RestAttemptLanding.Unsent];服务端裁决类([CyreneError.Server] / [CyreneError.Unauthorized])
|
||||
* 与配置族(`Tls` / `CleartextBlocked` / `InvalidUrl` / `Parse` / `Unknown`)⇒
|
||||
* [RestAttemptLanding.Errored]。
|
||||
*
|
||||
* ## fail-closed 落在哪里
|
||||
* 只有第 2 条不进队列,而它要求**回执明确说了「收下了」**。其余每一档不是进队列就是回滚——
|
||||
* 没有任何一条会因为「说不定通了」把一条没上路的消息当成发出去了,也没有任何一条会因为
|
||||
* 「反正会重试」把服务端的裁决吞掉。
|
||||
*/
|
||||
fun restAttemptLanding(attempt: Result<ChatAck>?): RestAttemptLanding = when {
|
||||
attempt == null -> RestAttemptLanding.Unsent
|
||||
|
||||
attempt.isSuccess -> {
|
||||
val ack = attempt.getOrThrow()
|
||||
if (ack.isAccepted()) RestAttemptLanding.Accepted(ack) else RestAttemptLanding.Refused(ack)
|
||||
}
|
||||
|
||||
// 异常不是 CyreneException(映射不出来)= 本端说不清服务端有没有收下 ⇒ 与网络族同一档。
|
||||
else -> when (val error = (attempt.exceptionOrNull() as? CyreneException)?.error) {
|
||||
null -> RestAttemptLanding.Unsent
|
||||
|
||||
else -> when (restFailureLanding(error)) {
|
||||
RestFailureLanding.Queue -> RestAttemptLanding.Unsent
|
||||
RestFailureLanding.Rollback -> RestAttemptLanding.Errored(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回落 REST 失败之后该去哪(**纯函数**):进队列,还是走今天那条回滚 + 文案。
|
||||
*
|
||||
* ## 为什么网络类失败进队列是安全的(主人 2026-10-05)
|
||||
* 重发用的还是**同一个** `clientMsgId`(就是那条乐观气泡的 `localId`,
|
||||
* [QueuedSend.localId]),而服务端按 `(session_id, client_msg_id)` 唯一索引仲裁:重复到达只回
|
||||
* `chat_duplicate`(WS)/ `{duplicate:true}`(REST),**不会再落库、不会再起一个回合**(契约 v0.56
|
||||
* §3.13)。所以「超时了但服务端其实收下了」这种最难判的情况不需要本端猜:进队列、以后重发一次,
|
||||
* 服务端自己会把重复吃掉。这也是「网络类失败可以自动补发」的**全部**依据——没有幂等键,这里只能
|
||||
* 回滚。
|
||||
*
|
||||
* ## 分档
|
||||
* - `null` ⇒ [RestFailureLanding.Queue]。`null` 的语义是「本端**没等到任何回执**」(自己设的
|
||||
* [REST_FALLBACK_TIMEOUT_MILLIS] 到点了,或抛了一个映射不出来的异常):服务端没给过裁决,
|
||||
* 这条就是「还没上路」,与网络类失败同一档。
|
||||
* - [CyreneError.Local] 里的网络族([CyreneError.LocalKind.NoNetwork] / [CyreneError.LocalKind.Dns] /
|
||||
* [CyreneError.LocalKind.Timeout] / [CyreneError.LocalKind.ConnectFailed])⇒
|
||||
* [RestFailureLanding.Queue]:请求根本没被服务端读到,等一下多半会好。
|
||||
* - 其余一切 ⇒ [RestFailureLanding.Rollback]:
|
||||
* - [CyreneError.Server] / [CyreneError.Unauthorized]:服务端**回了话**,那就是它的裁决(拒绝 /
|
||||
* 令牌失效)。盲目重发换不来不同结果,而且把「服务端拒绝」说成「还没连上」是假话;
|
||||
* - [CyreneError.Local] 里的配置与协议族(`Tls` / `CleartextBlocked` / `InvalidUrl` / `Parse` /
|
||||
* `Unknown`):**等下去不会变好**,它们要的是用户改设置或换地址,回滚时那句 `userMessage`
|
||||
* 正是他需要看到的东西。这几档在回落这条路上本来就极少出现——完全无网时 client 上的短连接
|
||||
* 超时([top.yeij.cyrene.data.api.CONNECT_TIMEOUT_MILLIS])先就把它拦下了。
|
||||
*
|
||||
* 用 `when` 穷举:错误模型将来多一档时,这里必须有人做决定,编不出档位就不该静默继承前一档。
|
||||
*/
|
||||
fun restFailureLanding(error: CyreneError?): RestFailureLanding = when (error) {
|
||||
null -> RestFailureLanding.Queue
|
||||
|
||||
is CyreneError.Local -> when (error.kind) {
|
||||
CyreneError.LocalKind.NoNetwork,
|
||||
CyreneError.LocalKind.Dns,
|
||||
CyreneError.LocalKind.Timeout,
|
||||
CyreneError.LocalKind.ConnectFailed,
|
||||
-> RestFailureLanding.Queue
|
||||
|
||||
CyreneError.LocalKind.Tls,
|
||||
CyreneError.LocalKind.CleartextBlocked,
|
||||
CyreneError.LocalKind.InvalidUrl,
|
||||
CyreneError.LocalKind.Parse,
|
||||
CyreneError.LocalKind.Unknown,
|
||||
-> RestFailureLanding.Rollback
|
||||
}
|
||||
|
||||
is CyreneError.Server, is CyreneError.Unauthorized -> RestFailureLanding.Rollback
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
import top.yeij.cyrene.domain.chat.ChatSendRouting
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.chat.SendChannel
|
||||
|
||||
/**
|
||||
* 「没连上、还没发出去」的一条待发消息([ChatUiState.queuedForConnection] 的元素)。
|
||||
*
|
||||
* 队列里只放两样东西:那条乐观气泡的 `localId`(= 上行幂等键 `clientMsgId`)与投递那一刻定下的
|
||||
* 并行声明。正文 / 图片 / 附件**不在这里再存一份**——它们已经在 [ChatUiState.pending] 那条气泡上,
|
||||
* 补发时从那里现取([ChatUiState.nextQueuedSend])。同一件事只留一个存放处:队列里再存一份正文,
|
||||
* 就多了一个会跟气泡分叉的真相源。
|
||||
*
|
||||
* [parallel] 是**投递那一刻**按「她忙时发消息」偏好算出来的
|
||||
* ([top.yeij.cyrene.domain.chat.parallelForSend]),补发照它发出、**不重读偏好**:补发的是同一条
|
||||
* 消息的**同一次投递**,排队途中用户切档不该让这条变成另一种投递,而重读还会让「这条消息的投递
|
||||
* 参数」随重连的时刻漂移。
|
||||
*/
|
||||
data class QueuedSend(
|
||||
/**
|
||||
* 乐观气泡的 `localId`,同时就是**上行幂等键** `clientMsgId`:补发**复用同一个键**,
|
||||
* 绝不铸第二个(契约 v0.56 §3.13,服务端按它判重、重复到达不会落成第二条消息)。
|
||||
*/
|
||||
val localId: String,
|
||||
/** 并行声明:只有 `true` 才会在帧上带 `parallel` 键([top.yeij.cyrene.domain.chat.parallelWireValue])。 */
|
||||
val parallel: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 这次发送**有没有东西可发**(**纯函数**,输入栏的 `send()` 与投递口 `emitAndDeliver` 共用同一次判定)。
|
||||
*
|
||||
* 三样里任意一样在就算有:正文(按 `isNotBlank`——调用方喂进来的本来就是 trim 过的,
|
||||
* 空白串与空串是同一件事)、图片、附件。三样都没有 ⇒ **什么都不发生**:不上屏乐观气泡、
|
||||
* 不进补发队列、不发任何帧、也不说一句话(用户按了个空发送键,没什么可说的)。
|
||||
*
|
||||
* 为什么把它从两处 `if` 里提出来做成一个函数:这条边界此前写在两个地方(输入栏的 `send()` 与
|
||||
* 投递口的 `emitAndDeliver`),两处写法一致但**没有一处能单测**——而「空输入不该发生任何事」正是
|
||||
* 一条要一直成立的边界。合成一个判据之后,入口与投递口用的**必须是同一条**:否则会出现
|
||||
* 「入口认为有内容、投递口认为没有」(或反过来)这种分叉。
|
||||
*/
|
||||
fun hasSendableContent(
|
||||
text: String,
|
||||
imageUri: String?,
|
||||
documents: List<DraftDocument>,
|
||||
): Boolean = text.isNotBlank() || imageUri != null || documents.isNotEmpty()
|
||||
|
||||
/** 一次 WS 投递的结论([wsSendOutcome] 的取值)。 */
|
||||
enum class WsSendOutcome {
|
||||
/** 帧已经交给 socket(`sendChat` 回 `true`):照今天的样子继续等回执,这里什么都不做。 */
|
||||
Dispatched,
|
||||
|
||||
/**
|
||||
* 未就绪 ⇒ 这条**不是失败**。W41 起它不再直接等于「进队列」:调用方(`ChatViewModel.sendThroughLadder`)
|
||||
* 接着往下判一次——**直接试 REST** 把这条发出去(W45 起不再先探连通性,理由见
|
||||
* [top.yeij.cyrene.ui.chat.sendRouteFor]),[WsState.Offline] 那一档与试不成的情况才留在待发态、
|
||||
* 等连接恢复后按发送顺序补发。无论哪一种,**都不回滚、不报错误横幅**。
|
||||
*/
|
||||
Queued,
|
||||
|
||||
/** 没发出去,而这一刻连接**就绪** ⇒ 真失败:走今天那条回滚 + 文案。 */
|
||||
Failed,
|
||||
}
|
||||
|
||||
/**
|
||||
* 「`sendChat` 的回值 + 这一刻的连接状态」⇒ 这条消息该怎么处置(**纯函数**,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么主人要的是队列而不是回滚(2026-10-05 裁决)
|
||||
* W39 之前,`deliver` 在拿到 `sent == false` 时一律 `rollback(...)`:气泡摘掉、正文退回输入栏,
|
||||
* 而全链路没有自动重发 ⇒ 用户看到的是一句「这条没发出去,请稍后手动重试」。断网 / 弱网下那句话
|
||||
* 把「网络还没好」说成了一次要用户负责的失败,而内容还得他自己看着输入栏再发一遍。主人的裁决是
|
||||
* **未连接就进队列**:不报错、内容不丢、连接恢复后自动补发。W38 给「分享落地」做的正是同一件事
|
||||
* ([ShareLanding]:等连接就绪才取走、恢复后自动补发)——这里沿用同一套语义,只是对象从
|
||||
* 「分享进来的内容」换成了「输入栏发出的这条」,所以两处的措辞与纪律也刻意保持一致。
|
||||
*
|
||||
* ## 判据与优先级
|
||||
* 1. `sent == true` ⇒ [WsSendOutcome.Dispatched]。帧已经交给 socket,连接状态那一格不再参与判断
|
||||
* (`sendChat` 判的就是它自己的状态)。**成功路径一个字都没动**:不打标记、不说告知、不写任何
|
||||
* 状态——回执(`pending_ack` / REST ack)、落库(`message_persisted`)、队列满(`pending_full`)
|
||||
* 各有各的路。
|
||||
* 2. `sent == false` 且连接**就绪**([WsState.Connected])⇒ [WsSendOutcome.Failed]。连接是好的,
|
||||
* 那 `false` 只可能来自另外两种成因(`webSocket` 对象已空 / 已被 cancel,或 `toJson()` / `send()`
|
||||
* 抛了异常),这时候「发送失败」是事实 ⇒ 走 [SEND_FAILED_MESSAGE] 那条既有回滚。
|
||||
* 3. 其余四档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting] / [WsState.Offline])
|
||||
* ⇒ [WsSendOutcome.Queued]。`ChatSocket.send` 的**第一行**就按连接状态挡掉,帧连序列化都没做、
|
||||
* 服务端一个字节都收不到 ⇒ 这不是「发送失败」,是**还没上路**:既不回滚,也不进 `error` 横幅。
|
||||
* (W41 起这一档由 `sendThroughLadder` 接着往下判:能直接试 REST 发就发,发不了才真进队列。
|
||||
* 本函数只回答「帧交出去了没有、连接是不是好的」,不掺那一次尝试——它要发请求,不能是纯函数。)
|
||||
*
|
||||
* ## 为什么 Offline 也进队列(而不像告知那样另分一档)
|
||||
* 主人的原话是「未连接就进队列」,四档一视同仁:[WsState.Offline] 只是**不会自己变好**,
|
||||
* 内容仍然一个字节没发出去、也一个字都不会丢;两档的差别只在**告知措辞**
|
||||
* ([queuedSendNoticeFor]:这一档说的是「重新登录 / 检查服务器地址」,而不是一个永远等不到的
|
||||
* 「连上后自动发出」)。队列照旧留着:用户重新登录 / 修好地址之后连接一旦就绪,它与其余三档
|
||||
* 一样按序补发。
|
||||
*
|
||||
* 判据只认 `Connected` **一个值**(不是「不等于 `Offline`」之类的近似)——见第 3 条:连接状态的
|
||||
* 另外四档都意味着帧没有被交给任何服务端。
|
||||
*/
|
||||
fun wsSendOutcome(sent: Boolean, connection: WsState): WsSendOutcome = when {
|
||||
sent -> WsSendOutcome.Dispatched
|
||||
connection == WsState.Connected -> WsSendOutcome.Failed
|
||||
else -> WsSendOutcome.Queued
|
||||
}
|
||||
|
||||
/**
|
||||
* 记下「这条没发出去、留在本端等连接」(**纯函数**,返回新状态)。
|
||||
*
|
||||
* 队列是**有序**的:新条目追加在末尾,顺序就是**用户发送的先后顺序**,补发照这个顺序走
|
||||
* ([ChatUiState.nextQueuedSend] 只看队头)。调用点只有一处(`deliver` 的「未就绪」分支),
|
||||
* 一条消息只会走到那里一次,所以不需要额外的去重表。
|
||||
*/
|
||||
fun ChatUiState.withQueuedSend(localId: String, parallel: Boolean): ChatUiState =
|
||||
copy(queuedForConnection = queuedForConnection + QueuedSend(localId, parallel))
|
||||
|
||||
/**
|
||||
* 出队(**纯函数**)。出队**恰好一次**正是「同一条不会被补发两次」的结构性保证:补发的每一步都
|
||||
* 只看队头,而队头只在这次投递有了结论(发出去了 / 真失败 / 气泡已经不在)之后才被摘掉
|
||||
* (见 [ChatUiState.nextQueuedSend] 与 `ChatViewModel.flushQueuedSends`)。
|
||||
*
|
||||
* 键不在队列里时**原样返回同一个实例**(Compose 的 key 稳定性靠它):回滚、收编、落库归一路过
|
||||
* 这里时,队列通常本来就是空的。
|
||||
*/
|
||||
fun ChatUiState.withoutQueuedSend(localId: String): ChatUiState =
|
||||
if (queuedForConnection.none { it.localId == localId }) {
|
||||
this
|
||||
} else {
|
||||
copy(queuedForConnection = queuedForConnection.filterNot { it.localId == localId })
|
||||
}
|
||||
|
||||
/** 这条待发气泡是不是「还没连上、留在本端等连接」的那一类(判据 = 它在补发队列里)。 */
|
||||
fun ChatUiState.isAwaitingConnection(localId: String?): Boolean =
|
||||
!localId.isNullOrBlank() && queuedForConnection.any { it.localId == localId }
|
||||
|
||||
/** 补发的下一步([ChatUiState.nextQueuedSend] 的取值)。 */
|
||||
sealed interface QueuedSendStep {
|
||||
/** 就发这条:键仍是 [localId](幂等键,不铸第二个),正文从那条气泡上现取。 */
|
||||
data class Send(val localId: String, val text: String, val parallel: Boolean) : QueuedSendStep
|
||||
|
||||
/** 气泡已经以别的路径了结(回滚 / 落库归一 / 收编都会把它带走)⇒ **只出队、不发**。 */
|
||||
data class Drop(val localId: String) : QueuedSendStep
|
||||
|
||||
/** 这条补发不了(带着图片或附件)⇒ 走今天那条失败路径:回滚 + 文案,内容一条不丢。 */
|
||||
data class Failed(val localId: String) : QueuedSendStep
|
||||
|
||||
/** 队列空:没什么要补的。 */
|
||||
data object Idle : QueuedSendStep
|
||||
}
|
||||
|
||||
/**
|
||||
* 补发的**下一步**(纯函数,VM 与测试共用):只看队头,**不出队**。
|
||||
*
|
||||
* 出队由调用方在这条有了结论之后做([ChatUiState.withoutQueuedSend]),于是「重复触发 ⇒ 不重复
|
||||
* 补发」落在结构上,而不是落在某一次调用的运气上:
|
||||
* - 同一批消息被**多次触发**(连接抖动、重组、重连之后立刻又断)时,每一步拿到的都是同一个键、
|
||||
* 同一段正文——触发器重复**不会**产生第二次出队;
|
||||
* - 出队之后再看,队头就是**下一条**,顺序因此严格等于入队顺序(用户发送的先后顺序);
|
||||
* - 就算某一帧真的被重复交出去(本端判据失效、进程被杀前重放之类),服务端仍按同一个
|
||||
* `clientMsgId` 判重(`chat_duplicate`,契约 v0.56 §3.13)⇒ 不会落成第二条消息。
|
||||
* 两道保证各管一头:**出队**管本端不重发,`clientMsgId` 管服务端不落第二条。
|
||||
*
|
||||
* 两种边界也要说清:
|
||||
* - 队头的键在 [ChatUiState.pending] 里找不到 ⇒ [QueuedSendStep.Drop]:气泡已经以别的路径了结
|
||||
* (回滚把它退回了输入栏、落库归一把它换成了权威消息),这时候再补发就是凭空多出一条。
|
||||
* - 气泡在、但它不是**纯文本**(带图 / 带附件)⇒ [QueuedSendStep.Failed]:WS 那条路只走纯文本
|
||||
* (有图 / 有附件一律 REST,见 [ChatSendRouting.channelFor]),只发正文会把图与附件**静默丢掉**。
|
||||
* 今天不可能出现(能进队列的仍然只有「本该走 WS 的短文本」那几条——W41 也没有放宽这一条:
|
||||
* 回落 REST 失败时进队列的同样是短文本,有图 / 有附件的那几条走的是另一条分支、压根不进这层阶梯),
|
||||
* 这道闸是给将来路由变化留的:真出现了宁可回滚、让用户自己看见并重发,也不要发出一条缺了图的消息。
|
||||
*/
|
||||
fun ChatUiState.nextQueuedSend(): QueuedSendStep {
|
||||
val head = queuedForConnection.firstOrNull() ?: return QueuedSendStep.Idle
|
||||
val item = pending.firstOrNull { it.localId == head.localId }
|
||||
?: return QueuedSendStep.Drop(head.localId)
|
||||
val channel = ChatSendRouting.channelFor(
|
||||
text = item.text,
|
||||
hasImage = !item.imageUri.isNullOrBlank(),
|
||||
hasDocuments = item.documents.isNotEmpty(),
|
||||
)
|
||||
return if (channel == SendChannel.Ws) {
|
||||
QueuedSendStep.Send(head.localId, item.text, head.parallel)
|
||||
} else {
|
||||
QueuedSendStep.Failed(head.localId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一刻的未就绪该说哪句话(**纯函数**;`null` = 这一刻没什么要说的)。
|
||||
*
|
||||
* 两档**只有措辞不同**:都只在「这条刚进了补发队列」时说一次(判据与不刷屏的记账在
|
||||
* `ChatViewModel.queueForConnection`),队列、补发时机、出队规则一个字都没改。
|
||||
*
|
||||
* - [WsState.Offline] ⇒ [SEND_QUEUED_OFFLINE_NOTICE]:这一档**不会自动重连**,对用户说「连上后
|
||||
* 自动发出」等的是一个**永远不会到来**的连接——那是拿一句不会兑现的承诺糊住一个需要他动手的
|
||||
* 状况(W39 给分享落地定过同一条规矩,见 [SHARE_OFFLINE_NOTICE])。所以这一档说的是他**能做的
|
||||
* 事**:重新登录(会话过期的路)或检查服务器地址(配置不对的路)。
|
||||
* - [WsState.Connected] ⇒ `null`:那一刻「排队等连接」这件事根本不成立——连接就绪时要么发出去
|
||||
* 要么走真失败那条路(见 [wsSendOutcome]),没有第三档。
|
||||
* - 其余三档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])⇒ [SEND_QUEUED_NOTICE]:
|
||||
* 只是还没连上,连上就会自动发出。
|
||||
*
|
||||
* 用 `when` 穷举,而不是「不是 Offline 就用另一句」:连接状态将来多一档时,这里必须有人做决定
|
||||
* (编不出文案就不该静默继承前一句)。
|
||||
*/
|
||||
fun queuedSendNoticeFor(connection: WsState): String? = when (connection) {
|
||||
WsState.Offline -> SEND_QUEUED_OFFLINE_NOTICE
|
||||
WsState.Connected -> null
|
||||
WsState.Idle, WsState.Connecting, WsState.Reconnecting -> SEND_QUEUED_NOTICE
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一条(或同一批)补发反复失败多少次之后**不再多说**(主人 2026-10-06 定案:多次重试失败就静默)。
|
||||
*
|
||||
* 取 3 的理由(**不许**取 1 或 2——那等于根本没告知):
|
||||
* - 第 1 次失败就是「刚进队列」那一刻,那一句必须说:它是「已经排上队了、不用重打一遍」的全部
|
||||
* 信息量所在;
|
||||
* - 第 2、3 次仍允许开口:连接抖动之后这条又没能上路,用户确实需要重新知道「还是没连上」,
|
||||
* 而不是以为早就发出去了;
|
||||
* - 到第 3 次仍未成功,这条(批)已经明显处在「反复失败」里了,再说一遍只是把同一件事刷第二遍
|
||||
* ——从此停下告知,静静地等(等的那件事没变:连接一好就自动补发)。
|
||||
*
|
||||
* 静默**不等于丢弃**:这个数只回答「还说不说」,一个字都不动补发队列本身——W38/W40/W41 那条
|
||||
* 「内容一个字节都不丢」的硬要求照旧成立。
|
||||
*/
|
||||
const val QUEUED_RESEND_SILENCE_THRESHOLD = 3
|
||||
|
||||
/**
|
||||
* 这一刻「这条(批)排队等连接」还该不该再说一句(**纯函数**,`false` = 静默)。
|
||||
*
|
||||
* ## 判据与优先级(两个条件同时成立才说)
|
||||
* 1. **静默闸优先**:[failedResends] 达到 [QUEUED_RESEND_SILENCE_THRESHOLD] ⇒ `false`,与
|
||||
* [toldThisRound] 是什么无关。这是主人 2026-10-06 的定案:反复重试都失败时停止刷屏。
|
||||
* 2. 轮内去重:[toldThisRound] 为真 ⇒ `false`(一轮「队列从空到空」只说一次,连续发几条离线
|
||||
* 消息不会每按一次发送就弹一遍——W40 既有的记账,一字不改)。
|
||||
* 3. 其余(这一轮还没说过、失败次数还没到阈值)⇒ `true`。
|
||||
*
|
||||
* [failedResends] 是**这条(批)已经失败过的补发次数**,只把「补发没能上路、内容仍留在队列里」
|
||||
* 那种失败计进来(见 `ChatViewModel.drainQueuedSends`):它是唯一会**反复发生**的一种;已被服务端
|
||||
* 裁决 / 已回滚的那两类是一次性了结(内容退回输入栏并给了明确原因),不在这条线上。
|
||||
*
|
||||
* 计数归零由调用方在做,判据是 [queuedResendFailuresAfterDelivery](**按事故复位**,主人
|
||||
* 2026-10-06 裁决):一次成功的补发清零,队列**排空**(这一批了结)同样清零。
|
||||
*/
|
||||
fun queuedSendNoticeAllowed(toldThisRound: Boolean, failedResends: Int): Boolean =
|
||||
!toldThisRound && failedResends < QUEUED_RESEND_SILENCE_THRESHOLD
|
||||
|
||||
/**
|
||||
* 一次出队 / 回滚之后,「这一批已经失败过几次」该记成多少(**纯函数**,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么按事故复位,而不是只在成功时清零(主人 2026-10-06 裁决)
|
||||
* 「事故」= 一次「队列从空到非空、再回到空」,与 [queuedSendNoticeAllowed] 的 `toldThisRound`
|
||||
* 同一个口径。**事故只要结束就复位,不要求它是靠一次成功的补发结束的**:W43 的旧口径只在补发
|
||||
* 成功时清零,于是当一段坏网络的队列**以回滚 / 被收编收场**(一次都没发出去)时,静默计数会一直
|
||||
* 挂在那里 ⇒ 之后连接恢复、再断一次、用户又发消息时,那句话永远说不出口。那不是「少点噪音」,
|
||||
* 是把解释**永久关掉**。
|
||||
*
|
||||
* 三种结束方式都是了结,都该复位:
|
||||
* - [sent] = 这一次补发真的上路了(`WsSendOutcome.Dispatched`)⇒ `0`;
|
||||
* - [drained] = 队列已经空了 ⇒ `0`(回滚把内容退回输入栏、被收编把气泡换成权威消息,都是了结);
|
||||
* - 其余(队列还有货、这一次又没成功)⇒ 原值:**同一个事故里**的静默照旧生效,不刷屏的规矩不变。
|
||||
*
|
||||
* 它只算「还说不说」,**从不**碰队列内容:静默从来不是丢弃。
|
||||
*/
|
||||
fun queuedResendFailuresAfterDelivery(failures: Int, sent: Boolean, drained: Boolean): Int =
|
||||
if (sent || drained) 0 else failures
|
||||
@@ -0,0 +1,236 @@
|
||||
package top.yeij.cyrene.ui.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.parse
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* 会话内搜索(D2)的**纯逻辑**:片段投影 / 结果合并 / 游标推进 / 「这条在不在窗口里」的判定。
|
||||
*
|
||||
* 为什么单独一层:这些判断全是「输入 → 输出」的确定性映射,且**错了也不会有异常**
|
||||
* (高亮错位、翻页卡住、谎报「已跳过去」都只是屏幕上看着不对),所以它们必须能被单测钉住,
|
||||
* 而不是藏在 Compose 的 `remember` 与协程里。UI 只做取值与渲染。
|
||||
*/
|
||||
|
||||
/** 命中片段一次最多显示多少个字符(含省略号):手机上一条结果两三行就够,再长没人读。 */
|
||||
const val SEARCH_SNIPPET_CHARS = 90
|
||||
|
||||
/** 省略号:片段被窗口裁过时标记「两头还有内容」。 */
|
||||
private const val ELLIPSIS = "…"
|
||||
|
||||
/** 任意连续空白(含换行)→ 单空格:结果列表是一行行的文本,不能带原始换行。 */
|
||||
private val WhitespaceRun = Regex("\\s+")
|
||||
|
||||
/**
|
||||
* 一条命中消息的摘要片段:**纯文本** + 命中区间。
|
||||
*
|
||||
* [matchStart] / [matchEnd] 为 -1 表示「在这段纯文本里定位不到这次命中」——**不是**错误,
|
||||
* 而是如实承认:服务端是在**原始 `content`** 上做 LIKE 的,而这里显示的是**剥掉标记之后**的
|
||||
* 文本,用户搜的词完全可能只出现在标记里(例如 `action` 这个词)。这种情况照旧显示片段,
|
||||
* 只是不画高亮,绝不把标记本身当命中去高亮一段用户没搜过的东西。
|
||||
*/
|
||||
data class SearchSnippet(
|
||||
val text: String,
|
||||
val matchStart: Int = -1,
|
||||
val matchEnd: Int = -1,
|
||||
) {
|
||||
/** 是否有一段可以放心画高亮的区间(越界一律当作没有,避免 `AnnotatedString` 抛异常)。 */
|
||||
val highlighted: Boolean
|
||||
get() = matchStart >= 0 && matchEnd > matchStart && matchEnd <= text.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息正文 → 摘要片段(带命中区间)。
|
||||
*
|
||||
* 正文必须先过 [parse](项目里唯一的 Web 端标记解析器)再拼回纯文本:服务端存的是
|
||||
* `<p>` / `<action>` / `<narration>` / `*…*` 这套标记,直接渲染出来就是一堆尖括号。
|
||||
* 「拼成纯文本再上色」与「把标记当富文本渲染」是两件事——后者本端从来没做过(见 `05` 的
|
||||
* 「别假设文本字段就是纯文本」)。片段里保留**全部**片段文本(正文/旁白/动作一视同仁),
|
||||
* 因为命中可能落在旁白里,只投影正文会把那次命中藏起来。
|
||||
*/
|
||||
fun searchSnippet(
|
||||
content: String,
|
||||
query: String,
|
||||
maxChars: Int = SEARCH_SNIPPET_CHARS,
|
||||
): SearchSnippet {
|
||||
val plain = plainSearchText(content)
|
||||
if (plain.isEmpty()) return SearchSnippet("")
|
||||
val needle = query.trim()
|
||||
val match = if (needle.isEmpty()) -1 else plain.indexOf(needle, startIndex = 0, ignoreCase = true)
|
||||
if (match < 0) {
|
||||
val tail = if (plain.length > maxChars) ELLIPSIS else ""
|
||||
return SearchSnippet(plain.take(maxChars) + tail)
|
||||
}
|
||||
return window(plain, match, match + needle.length, maxChars)
|
||||
}
|
||||
|
||||
/** 标记 → 纯文本:按渲染顺序取片段文本,再把空白折成单空格。 */
|
||||
internal fun plainSearchText(raw: String): String =
|
||||
parse(raw)
|
||||
.joinToString(" ") { bubble -> bubble.parts.joinToString(" ") { it.text } }
|
||||
.replace(WhitespaceRun, " ")
|
||||
.trim()
|
||||
|
||||
/** 以命中为中心开一个窗:命中前后都留一点上下文,窗口两端有裁剪就补省略号(下标随之平移)。 */
|
||||
private fun window(plain: String, matchStart: Int, matchEnd: Int, maxChars: Int): SearchSnippet {
|
||||
if (plain.length <= maxChars) return SearchSnippet(plain, matchStart, matchEnd)
|
||||
|
||||
// 命中前留 1/4 的窗口做上下文:命中点偏左比偏右好读,但也别把命中顶到第一列。
|
||||
val lead = maxChars / 4
|
||||
val start = (matchStart - lead).coerceIn(0, (plain.length - maxChars).coerceAtLeast(0))
|
||||
val end = (start + maxChars).coerceAtMost(plain.length)
|
||||
val prefix = if (start > 0) ELLIPSIS else ""
|
||||
val suffix = if (end < plain.length) ELLIPSIS else ""
|
||||
val text = prefix + plain.substring(start, end) + suffix
|
||||
return SearchSnippet(
|
||||
text = text,
|
||||
matchStart = (matchStart - start + prefix.length).coerceIn(0, text.length),
|
||||
matchEnd = (matchEnd - start + prefix.length).coerceIn(0, text.length),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 结果条目上的角色标签。
|
||||
*
|
||||
* `role` 在后端是**开放集合**(`05` 的「易忘的实现约定」):`user` / `assistant` 之外的取值
|
||||
* **原样显示**,不丢弃、不占位——将来多一个 `system` 也不会被显示成空白。
|
||||
*/
|
||||
fun searchRoleLabel(role: String): String = when (role) {
|
||||
"user" -> "我"
|
||||
"assistant" -> "昔涟"
|
||||
else -> role
|
||||
}
|
||||
|
||||
/**
|
||||
* 结果条目上的时间:Unix 毫秒(服务端时间)→ 本地 `MM-dd HH:mm`。
|
||||
*
|
||||
* 刻意**不复用** `domain/admin` 的 `formatEpochMillis`:那是给「一行放得下一整串」的详情页用的
|
||||
* `yyyy-MM-dd HH:mm:ss`,而搜索结果一行里还要放角色与命中片段,秒级时间会把左列挤没。
|
||||
* 已知取舍:跨年时看不出年份——搜索是「在这个会话里找刚说过的话」,这个精度够用。
|
||||
* `zone` 可注入,因此这条映射是纯函数(单测钉住时区)。
|
||||
*/
|
||||
fun searchHitTime(millis: Long, zone: ZoneId = ZoneId.systemDefault()): String =
|
||||
Instant.ofEpochMilli(millis).atZone(zone).format(SearchHitTimeFormat)
|
||||
|
||||
private val SearchHitTimeFormat = DateTimeFormatter.ofPattern("MM-dd HH:mm")
|
||||
|
||||
/**
|
||||
* 搜索结果排序:`createdAt` **倒序**(新 → 旧),同毫秒用 `id` 倒序兜底。
|
||||
*
|
||||
* 与聊天页的 [top.yeij.cyrene.data.repository.MessageOrder] 方向**刻意相反**:搜索结果是
|
||||
* 「按时间倒序的一条条命中」(契约 §1 行 6),翻页也是往更早翻,UI 因此可以直接顺序渲染。
|
||||
*/
|
||||
internal val SearchHitOrder: Comparator<HistoryMessageDto> =
|
||||
compareByDescending<HistoryMessageDto> { it.createdAt }.thenByDescending { it.id }
|
||||
|
||||
/** 一页搜索结果并进已有结果后的结果集与「还能不能往下翻」。 */
|
||||
data class SearchPageMerge(
|
||||
val hits: List<HistoryMessageDto>,
|
||||
val hasMore: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 合并一页搜索结果:按 `id` 去重(重复到达的以**新到的**为准)、按 [SearchHitOrder] 重排。
|
||||
*
|
||||
* [hasMore] 是 `pageHasMore && 真的变多了`——这一条不是洁癖,而是**防死循环**:`before` 是
|
||||
* 「上一页最旧那条的时间戳」,若服务端把同毫秒的一批消息又回了一遍,游标不会前进,
|
||||
* 于是「还能翻 → 再请求 → 又全是重复」会永远转下去。只有结果集真的长大才允许继续翻。
|
||||
*/
|
||||
fun mergeSearchPage(
|
||||
existing: List<HistoryMessageDto>,
|
||||
incoming: List<HistoryMessageDto>,
|
||||
pageHasMore: Boolean,
|
||||
): SearchPageMerge {
|
||||
if (incoming.isEmpty()) return SearchPageMerge(existing, false)
|
||||
|
||||
val byId = LinkedHashMap<String, HistoryMessageDto>(existing.size + incoming.size)
|
||||
existing.forEach { byId[it.id] = it }
|
||||
incoming.forEach { byId[it.id] = it }
|
||||
val merged = byId.values.sortedWith(SearchHitOrder)
|
||||
return SearchPageMerge(merged, pageHasMore && merged.size > existing.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一页的 `before` 游标 = **已加载结果里最旧那条**的 `createdAt`。
|
||||
*
|
||||
* 取整个结果集的极小值(而不是「这一页的最小值」):翻页轮次变多时,两页之间若出现乱序
|
||||
* (服务端同毫秒并列、或某条被别的端删了),极小值仍然单调不增,不会跳回去重拉一段。
|
||||
*/
|
||||
fun nextSearchCursor(hits: List<HistoryMessageDto>): Long? = hits.minOfOrNull { it.createdAt }
|
||||
|
||||
/** 点一条搜索结果之后要做的事。 */
|
||||
sealed interface SearchJump {
|
||||
|
||||
/**
|
||||
* 命中就在**当前已加载的窗口**里:[lazyIndex] 是聊天列表(`reverseLayout`)里的项下标,
|
||||
* [messageId] 用于把「滚的是哪一条」说清楚。
|
||||
*/
|
||||
data class ScrollTo(val lazyIndex: Int, val messageId: String) : SearchJump
|
||||
|
||||
/**
|
||||
* 命中**不在**窗口里。这里刻意不给「滚过去」的假动作,[notice] 是给用户看的一句人话。
|
||||
*/
|
||||
data class NotLoaded(val notice: String) : SearchJump
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定一条命中能不能定位,并给出定位下标。
|
||||
*
|
||||
* ## 为什么不在窗口里时**不**把它插进来显示
|
||||
* 「插一条临时消息」看似更讨好,代价却是真的:聊天列表是
|
||||
* `ChatUiState.items`(已落库 + 待发 + 流式中)按 `createdAt` 升序倒过来喂给
|
||||
* `reverseLayout` 的,凭空插一条几千条之前的历史消息进去,它会出现在**列表最底**、
|
||||
* 也就是「刚刚发出的位置」,与它自己的时间戳矛盾;跟随底部的逻辑(`followBottom`)也会被
|
||||
* 这条永远不该在底部的东西带偏。而本模块**不许改 `ChatViewModel`**(它归另一个并行模块),
|
||||
* 因此没有「把这条真的并进消息窗」的合法路径。
|
||||
* ⇒ 如实说一句「这条不在当前已加载的窗口里」并告诉用户下一步(上滑加载更早的消息),
|
||||
* 比一个假的跳转诚实得多,也正是本任务书要求的那条底线。
|
||||
*
|
||||
* @param loadedAscending 聊天页**已加载**的落库消息(升序,即 `ChatUiState.messages`)
|
||||
* @param displayItemCount `ChatUiState.items` 的条数:待发与流式气泡排在已落库消息之后,
|
||||
* 所以「下拉列表下标 = displayItemCount - 1 - 升序下标」对已落库消息恒成立
|
||||
*/
|
||||
fun planSearchJump(
|
||||
hit: HistoryMessageDto,
|
||||
loadedAscending: List<HistoryMessageDto>,
|
||||
displayItemCount: Int,
|
||||
): SearchJump {
|
||||
val index = loadedAscending.indexOfFirst { it.id == hit.id }
|
||||
if (index >= 0) {
|
||||
val lazyIndex = (displayItemCount - 1 - index).coerceAtLeast(0)
|
||||
return SearchJump.ScrollTo(lazyIndex = lazyIndex, messageId = hit.id)
|
||||
}
|
||||
|
||||
val oldest = loadedAscending.firstOrNull()?.createdAt
|
||||
val newest = loadedAscending.lastOrNull()?.createdAt
|
||||
val notice = when {
|
||||
loadedAscending.isEmpty() -> "这条消息还没加载到本地,先等会话加载完再来"
|
||||
oldest != null && hit.createdAt < oldest -> "这条比已加载的最早一条还早,先上滑加载更早的消息再来"
|
||||
newest != null && hit.createdAt > newest -> "这条比已加载的最新一条还新,等它出现在会话里再来"
|
||||
else -> "这条不在当前已加载的窗口里(可能已在别处删除),没有跳过去"
|
||||
}
|
||||
return SearchJump.NotLoaded(notice)
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索失败 → 一句人话。
|
||||
*
|
||||
* 与「没有找到」**必须分开**:这两种在界面上长得像(都是「列表是空的」),但用户该做的事完全
|
||||
* 不同——一个是换关键词,一个是修网络/权限。所以失败一律走这里,绝不落进空结果分支。
|
||||
*/
|
||||
fun searchFailureMessage(throwable: Throwable): String {
|
||||
val error = (throwable as? CyreneException)?.error
|
||||
return when {
|
||||
error is CyreneError.Unauthorized -> "登录状态已失效,请重新登录"
|
||||
error is CyreneError.Server && error.httpStatus == 403 -> "当前账号没有权限搜索这个会话"
|
||||
error is CyreneError.Server && error.code == "not_implemented" ->
|
||||
"服务端还没有开启消息存储,搜索暂时不可用"
|
||||
|
||||
error != null -> "搜索失败:${error.userMessage}"
|
||||
else -> "搜索失败,请稍后重试"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.basic.Close
|
||||
import top.yukonga.miuix.kmp.icon.basic.Search
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 结果列表末尾还剩几项时就开始拉下一页(与聊天页的「接近末尾就加载更早」同一手感)。 */
|
||||
private const val LOAD_MORE_THRESHOLD = 3
|
||||
|
||||
/**
|
||||
* 会话内搜索面板:**盖住整个聊天页**(含顶栏与输入栏)的一层。
|
||||
*
|
||||
* 为什么不换掉聊天页的 Scaffold 而是叠一层:搜索期间底下的消息列表必须**保持挂载**——
|
||||
* 用户点一条命中要 `animateScrollToItem` 定位,而 `LazyListState` 一旦对应的 LazyColumn 被
|
||||
* 移出组合就不再 attach,滚动会落空。叠一层则底下的列表一直在,定位随时可用。
|
||||
*
|
||||
* 自己的顶栏只做一件事:关闭(返回键走 ChatScreen 里那条 [androidx.activity.compose.BackHandler],
|
||||
* 两条路径都只关搜索,不动抽屉/设置/管理的层级)。
|
||||
*/
|
||||
@Composable
|
||||
fun SessionSearchPanel(
|
||||
state: SessionSearchUiState,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
onRetryMore: () -> Unit,
|
||||
onHitClick: (HistoryMessageDto) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// 不透明底色:面板是叠在聊天页上的,漏底色就会看到两层文字叠在一起。
|
||||
Box(modifier = modifier.fillMaxSize().background(MiuixTheme.colorScheme.surface)) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SmallTopAppBar(
|
||||
title = "搜索会话",
|
||||
// subtitle 是非空 String(与聊天页顶栏同一口径:没有副标题就传空串)。
|
||||
subtitle = if (state.hits.isEmpty()) "" else "${state.hits.size} 条命中",
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = "关闭搜索")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding(),
|
||||
) {
|
||||
SearchField(
|
||||
value = state.query,
|
||||
onChange = onQueryChange,
|
||||
onSubmit = onSubmit,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
state.searching -> SearchHint("搜索中…")
|
||||
// 失败与「没找到」在这里分开:两者都是空列表,但用户要做的事完全不同。
|
||||
state.failedWithoutResults -> SearchFailure(state.failure.orEmpty(), onSubmit)
|
||||
state.emptyResult -> SearchHint("没有找到包含「${state.submitted}」的消息")
|
||||
state.hits.isEmpty() -> SearchHint("输入关键词,搜索这个会话里的消息")
|
||||
else -> SearchResultList(
|
||||
state = state,
|
||||
onLoadMore = onLoadMore,
|
||||
onRetryMore = onRetryMore,
|
||||
onHitClick = onHitClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键词输入框:**自动聚焦**(进搜索态就能直接打字是这类界面唯一的意义),
|
||||
* IME 的搜索键与右侧的放大镜**都能提交**——模拟器/外接键盘上按 IME 搜索键不一定方便,
|
||||
* 留一个能点的入口才不至于「打了字没反应」。
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchField(
|
||||
value: String,
|
||||
onChange: (String) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// 等第一帧:刚 composition 出来时输入框还没 attach,直接 requestFocus 会抛 IllegalState。
|
||||
withFrameNanos { }
|
||||
runCatching { focusRequester.requestFocus() }
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onChange,
|
||||
label = "搜索这个会话里的消息",
|
||||
useLabelAsPlaceholder = true,
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
|
||||
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
IconButton(onClick = onSubmit) {
|
||||
Icon(imageVector = MiuixIcons.Basic.Search, contentDescription = "开始搜索")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultList(
|
||||
state: SessionSearchUiState,
|
||||
onLoadMore: () -> Unit,
|
||||
onRetryMore: () -> Unit,
|
||||
onHitClick: (HistoryMessageDto) -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow {
|
||||
val info = listState.layoutInfo
|
||||
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||
info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - LOAD_MORE_THRESHOLD
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { nearEnd -> if (nearEnd) onLoadMore() }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = state.hits, key = { it.id }) { hit ->
|
||||
SearchResultRow(
|
||||
hit = hit,
|
||||
query = state.submitted,
|
||||
onClick = { onHitClick(hit) },
|
||||
)
|
||||
}
|
||||
item(key = "__search_more__") {
|
||||
SearchFooter(state = state, onLoadMore = onLoadMore, onRetryMore = onRetryMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表尾部:翻页进度 / 翻页失败(带重试)/ 「还有更早的命中」。
|
||||
*
|
||||
* 翻页失败**不清空**已经搜到的结果,只在尾部说明——前几页是可信的,丢掉它们等于惩罚用户,
|
||||
* 而「重试」按钮用的是同一份游标,不会重复拉已经拿到的那些。
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchFooter(
|
||||
state: SessionSearchUiState,
|
||||
onLoadMore: () -> Unit,
|
||||
onRetryMore: () -> Unit,
|
||||
) {
|
||||
val hint = when {
|
||||
state.loadingMore -> "加载更早的命中…"
|
||||
state.failure != null -> state.failure
|
||||
state.hasMore -> "上滑加载更早的命中"
|
||||
else -> null
|
||||
}
|
||||
if (hint == null) return
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = hint,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = if (state.failure != null) {
|
||||
MiuixTheme.colorScheme.error
|
||||
} else {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
},
|
||||
)
|
||||
if (state.failure != null && !state.loadingMore) {
|
||||
TextButton(text = "重试", onClick = onRetryMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条命中:角色 + 命中片段(高亮)+ 时间。
|
||||
*
|
||||
* 片段是**纯文本**投影([searchSnippet],先过 `domain/parse` 的解析器),高亮只是在这一段纯文本
|
||||
* 上加一个 `SpanStyle` 底色——本项目**从不**把 `<p>` / `<action>` 当富文本渲染,
|
||||
* 这里也不开这个口子。
|
||||
*
|
||||
* 列表项用 `BasicComponent` 作底座(Miuix 的列表项约定),它自己没有背景与圆角,
|
||||
* 因此必须包一层 [PreferenceCard]。
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchResultRow(
|
||||
hit: HistoryMessageDto,
|
||||
query: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val scheme = MiuixTheme.colorScheme
|
||||
val snippet = remember(hit.id, hit.content, query) { searchSnippet(hit.content, query) }
|
||||
// 恒定是 AnnotatedString:Miuix 的 Text 只有 String 与 AnnotatedString 两个重载,
|
||||
// 让分支类型收敛成 CharSequence 会两个都匹配不上。
|
||||
val highlighted = remember(snippet, scheme.primaryContainer, scheme.onPrimaryContainer) {
|
||||
buildAnnotatedString {
|
||||
if (!snippet.highlighted) {
|
||||
append(snippet.text)
|
||||
} else {
|
||||
append(snippet.text.substring(0, snippet.matchStart))
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
background = scheme.primaryContainer,
|
||||
color = scheme.onPrimaryContainer,
|
||||
),
|
||||
) {
|
||||
append(snippet.text.substring(snippet.matchStart, snippet.matchEnd))
|
||||
}
|
||||
append(snippet.text.substring(snippet.matchEnd))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
onClick = onClick,
|
||||
endActions = {
|
||||
Text(
|
||||
text = searchHitTime(hit.createdAt),
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = scheme.onSurfaceVariantSummary,
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = searchRoleLabel(hit.role),
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = scheme.primary,
|
||||
maxLines = 1,
|
||||
)
|
||||
// 纯图片 / 纯附件的回合没有正文,如实说明而不是显示一行空白。
|
||||
if (snippet.text.isEmpty()) {
|
||||
Text(
|
||||
text = "(这条消息没有文字内容)",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = scheme.onSurfaceVariantSummary,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = highlighted,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = scheme.onSurfaceContainer,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 首批失败:整页显示原因 + 重试(**不**渲染成「没找到」)。 */
|
||||
@Composable
|
||||
private fun SearchFailure(message: String, onRetry: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = message.ifBlank { "搜索失败,请稍后重试" },
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextButton(text = "重试", onClick = onRetry)
|
||||
}
|
||||
}
|
||||
|
||||
/** 居中一句提示(初始态 / 搜索中 / 没找到)。 */
|
||||
@Composable
|
||||
private fun SearchHint(text: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.repository.normalizeSearchQuery
|
||||
|
||||
/**
|
||||
* 会话内搜索(D2)的状态。
|
||||
*
|
||||
* @param query 输入框里的原文
|
||||
* @param submitted **已经发给服务端**的那个关键词。与 [query] 分开是必须的:结果属于
|
||||
* `submitted`,而用户可能正在打下一个词——空结果的文案要说的是「没有找到包含 X 的消息」,
|
||||
* 那个 X 只能是 `submitted`,否则会声称「没找到 abc」而其实搜的是 ab。
|
||||
* @param started 是否**至少提交过一次**。初始态与「搜过但没命中」都是空列表,
|
||||
* 只有它能区分「还没搜」与「没找到」([emptyResult])。
|
||||
* @param failure 最近一次失败的人话。[hits] 为空时整页显示它,非空时挂在列表尾部——
|
||||
* 两种都不是「没找到」。
|
||||
*/
|
||||
data class SessionSearchUiState(
|
||||
val query: String = "",
|
||||
val submitted: String = "",
|
||||
val started: Boolean = false,
|
||||
val searching: Boolean = false,
|
||||
val loadingMore: Boolean = false,
|
||||
val hits: List<HistoryMessageDto> = emptyList(),
|
||||
val hasMore: Boolean = false,
|
||||
val nextCursor: Long? = null,
|
||||
val failure: String? = null,
|
||||
) {
|
||||
/** 真的搜过、请求成功、结果为空 —— 这才是「没有找到」。 */
|
||||
val emptyResult: Boolean
|
||||
get() = started && failure == null && !searching && hits.isEmpty()
|
||||
|
||||
/** 首批就失败(列表里什么都没有):整页的失败态。翻页失败走 [failure] + [hits] 非空那条路。 */
|
||||
val failedWithoutResults: Boolean
|
||||
get() = failure != null && hits.isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话内搜索的小 ViewModel:**只属于聊天页的搜索态**,与 [ChatViewModel] 完全不相干——
|
||||
* 搜索不改变消息窗、不碰 WS、不发消息,所以它不该住进那个正在被另一个模块改动的 VM 里。
|
||||
*
|
||||
* 作用域由调用方决定(`viewModel(key = "session-search:<账号指纹>|<id>")`),因此每个会话各有一份、
|
||||
* 切回来时上一次的查询与结果还在;而**换账号/换服务器后指纹变了 ⇒ 取到的是新实例**,
|
||||
* 面板里不会残留上一个环境的命中(那些命中就是别的服务器的消息正文)。
|
||||
* 取数一律走既有的 `MessageRepository`。
|
||||
*
|
||||
* 并发:`generation` 是「谁的结果才算数」的代际号。搜索是**用户连打两次回车**就会并发的地方,
|
||||
* 加上翻页与首批可以同时存在,没有代际号就会出现「先发的慢请求后到,把新关键词的结果顶掉」。
|
||||
* 每次 [submit] 都换代并取消上一个 job,旧代的结果回来直接丢。
|
||||
*/
|
||||
class SessionSearchViewModel(
|
||||
private val container: AppContainer,
|
||||
private val sessionId: String,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(SessionSearchUiState())
|
||||
val state: StateFlow<SessionSearchUiState> = _state.asStateFlow()
|
||||
|
||||
private var job: Job? = null
|
||||
private var generation = 0
|
||||
|
||||
/** 输入框变化:**不发请求**。让用户把词打完,提交由 IME 的搜索键或搜索按钮触发。 */
|
||||
fun onQueryChange(value: String) {
|
||||
_state.update { it.copy(query = value) }
|
||||
}
|
||||
|
||||
/** 提交当前关键词:从第一页(最新的命中)开始搜。空白关键词按「不发请求」处理。 */
|
||||
fun submit() {
|
||||
val normalized = normalizeSearchQuery(_state.value.query) ?: return
|
||||
val generation = ++this.generation
|
||||
job?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
submitted = normalized,
|
||||
started = true,
|
||||
searching = true,
|
||||
loadingMore = false,
|
||||
hits = emptyList(),
|
||||
hasMore = false,
|
||||
nextCursor = null,
|
||||
failure = null,
|
||||
)
|
||||
}
|
||||
job = viewModelScope.launch {
|
||||
val result = container.messageRepository.search(sessionId, normalized)
|
||||
if (generation != this@SessionSearchViewModel.generation) return@launch
|
||||
result.fold(
|
||||
onSuccess = { page ->
|
||||
val merge = mergeSearchPage(emptyList(), page.messages, page.hasMore)
|
||||
_state.update {
|
||||
it.copy(
|
||||
searching = false,
|
||||
hits = merge.hits,
|
||||
hasMore = merge.hasMore,
|
||||
nextCursor = nextSearchCursor(merge.hits),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { throwable ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
searching = false,
|
||||
hits = emptyList(),
|
||||
hasMore = false,
|
||||
nextCursor = null,
|
||||
failure = searchFailureMessage(throwable),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 往下翻:加载**更早**的命中(`before` + `limit`)。自动触发(滚到列表末尾)与手动重试共用。
|
||||
*
|
||||
* @param force 手动重试时为 true:翻页失败后 [SessionSearchUiState.hasMore] 已被置 false
|
||||
* (那正是「不许自动重试」的实现),只有用户明确点重试才允许再打一次。
|
||||
*/
|
||||
fun loadMore(force: Boolean = false) {
|
||||
val current = _state.value
|
||||
if (current.searching || current.loadingMore) return
|
||||
if (!current.hasMore && !force) return
|
||||
val cursor = current.nextCursor ?: return
|
||||
val generation = this.generation
|
||||
_state.update { it.copy(loadingMore = true, failure = null) }
|
||||
job = viewModelScope.launch {
|
||||
val result = container.messageRepository.search(
|
||||
sessionId = sessionId,
|
||||
query = current.submitted,
|
||||
before = cursor,
|
||||
)
|
||||
if (generation != this@SessionSearchViewModel.generation) return@launch
|
||||
result.fold(
|
||||
onSuccess = { page ->
|
||||
val merge = mergeSearchPage(current.hits, page.messages, page.hasMore)
|
||||
_state.update {
|
||||
it.copy(
|
||||
loadingMore = false,
|
||||
hits = merge.hits,
|
||||
hasMore = merge.hasMore,
|
||||
nextCursor = nextSearchCursor(merge.hits),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { throwable ->
|
||||
// 失败**不动**已有结果:搜到的东西是可信的,只是更早的那一页没取回来。
|
||||
_state.update {
|
||||
it.copy(
|
||||
loadingMore = false,
|
||||
hasMore = false,
|
||||
failure = searchFailureMessage(throwable),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
|
||||
/**
|
||||
* 「一份**已经取走**、正等着用户确认的分享」,外加用户此刻补的那句描述。
|
||||
*
|
||||
* ## 为什么它必须活在 VM 里,而不是一格 `remember`
|
||||
* 这份内容**已经**从待发队列里出队了([top.yeij.cyrene.app.ShareIntake.take] 是原子出队、
|
||||
* **没有归还路径**),而它此前只挂在一格 `remember(sessionId)` 上——那格状态属于**那一次组合**。
|
||||
* Activity 因转屏 / 深浅色切换重建时组合整份丢掉:那份分享既不在队列里(已经出队)、也不在界面上
|
||||
* (那格状态没了),**静默消失**——用户什么都没看到,也没有任何提示。所以它待在
|
||||
* [ChatUiState.shareConfirm] 里:VM 由 `AppContainer` 那一层持有(`core.session.ChatSessionSlots`
|
||||
* 的 `ViewModelStore`),比组合与 Activity 都活得久。
|
||||
*
|
||||
* 正文([share])与描述([caption])**绑在同一个值里**是刻意的:描述说的是「你要补的那一句话」,
|
||||
* 它只对这一次确认有意义——分成两格迟早会出现「描述换了一份、正文还是上一份」。
|
||||
*
|
||||
* [caption] 初值**恒为空**:面板上半是这次分享进来的东西,下半才是用户要附加的话,预填一次就会在
|
||||
* 面板里把同一句话排两遍(W53X 的第一版正是这么错的,见 `ShareConfirmPanel` 的 KDoc)。
|
||||
*/
|
||||
data class ShareConfirmDraft(
|
||||
val share: SharedContent,
|
||||
val caption: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* 「手上这一份 + 去待发队列认领一次」⇒ 面板这一刻该拿着哪一份(**纯函数**,可单测)。
|
||||
*
|
||||
* ## 它回答的是**不可逆那一步该不该走**
|
||||
* [take] 就是 `ShareIntake.take()`(**不可逆**),所以「什么时候允许调它」是这里唯一的正事:
|
||||
* **手上已经有东西时一个字节都不动**——连队列里新来的那条也不碰,它原地等着,面板上此刻摆的
|
||||
* 仍是用户正在看的那一份,不会被后来的一条顶掉。这就是「同一份分享只被取走一次」的落点:
|
||||
* Activity 重建之后新的一帧会再问一次同样的问题,而那时 [holding] 里已经装着那份内容
|
||||
* (它活在 VM 的状态里,活过了重建)⇒ 不会再 [take] 第二次。
|
||||
*
|
||||
* ## 为什么不是「取完之后再拿结果比一比」
|
||||
* 不能靠「第二次 [take] 拿到 null 就当没发生过」兜底:第二次跑起来拿到的要么是 null(白跑一趟),
|
||||
* 要么是**队列里的下一条**——后者会把用户正看着的那份顶掉,那是一次静默丢失。所以闸门必须在
|
||||
* [take] **之前**,而不是在它之后回滚。
|
||||
*
|
||||
* @param holding 这一刻面板手上那一份([ChatUiState.shareConfirm]);null = 手上没东西。
|
||||
* @param take 去待发队列认领一条(`ShareIntake.take()`):**只在 [holding] 为 null 时被调用**,
|
||||
* 且最多一次。返回 null(队列为空)时本函数也回 null——不造一个「什么都没有的面板」。
|
||||
* @return 面板该拿着的那一份:手上原有的**原样**返回(连实例都不换——`ShareConfirmPanel` 用它做
|
||||
* `remember` 的键,换一份等值的新对象会让预览白重建一次);手上没有时就是刚认领的那一条。
|
||||
*/
|
||||
internal fun shareConfirmClaim(
|
||||
holding: ShareConfirmDraft?,
|
||||
take: () -> SharedContent?,
|
||||
): ShareConfirmDraft? = holding ?: take()?.let { ShareConfirmDraft(share = it) }
|
||||
@@ -0,0 +1,757 @@
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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
|
||||
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
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.window.WindowDialog
|
||||
|
||||
/**
|
||||
* 「这次分享进来的内容」的确认面板(主人 2026-10-06 拍板:**像其他 IM 一样,先确认再发**)。
|
||||
*
|
||||
* ## 它补上的那一步
|
||||
* 在这之前,分享进来的内容一到连接就绪就被 `ChatViewModel.sendShared` 直接发了出去
|
||||
* (见 `ChatScreen` 里那段落地的 effect),用户**没有任何确认机会**:一张图、一份文件、
|
||||
* 一段从浏览器带来的链接,点完分享就已经在对话里了。面板把这一步补上——**只有点「发送」才真的发**,
|
||||
* 「取消」则本次分享作废(不发送、也不塞回待发队列:那会让「用户刚刚作废的那一份」与「又来了
|
||||
* 新分享」混成同一件事,见 [top.yeij.cyrene.domain.share.PendingShareHolder])。
|
||||
*
|
||||
* ## 取走只发生一次,面板只负责显示
|
||||
* 这份 [share] **已经**从待发队列里被 `ShareIntake.take()` 取走了(原子出队、没有归还路径),
|
||||
* 它现在活在 `ChatScreen` 的 Compose 状态里。所以本文件里的东西全是**无状态**的:面板不取内容、
|
||||
* 不记账、也不判断「该不该弹」——那些判据与记账都在调用方,这里只把拿到的那一份画出来。
|
||||
*
|
||||
* ## 为什么用 Miuix 的 `WindowDialog`
|
||||
* 项目里其余四个弹层(编辑 / 删除 / 附件预览 / 图片预览)用的是 Compose 的 `androidx.compose.ui.window.Dialog`
|
||||
* + Miuix 内容,因为它当年在 Miuix 里找不到可用的弹层控件。Miuix 0.9.3 其实自带两族(**没有**旧版本的
|
||||
* `extra.*` 包,别照那个去猜 import):`top.yukonga.miuix.kmp.window.*`(`WindowDialog` /
|
||||
* `WindowBottomSheet`,真正的弹窗)与 `top.yukonga.miuix.kmp.overlay.*`(`OverlayDialog` /
|
||||
* `OverlayBottomSheet`,在当前组合里当一层浮层)。
|
||||
*
|
||||
* 本面板用 **window 族**,两个理由:
|
||||
*
|
||||
* 1. **overlay 族在调用点上是走不通的**(不是偏好,是硬约束):`OverlayDialog` 靠
|
||||
* `MiuixPopupUtils.DialogLayout` 把自己登记进 `LocalDialogStates` / `LocalRootDialogStates`,
|
||||
* 而这两个 `staticCompositionLocalOf` **只有 Miuix 的 `Scaffold` 会 provide**(`basic/Scaffold.kt`)。
|
||||
* 本面板的调用点在 `ChatScreen` 里那个 `Scaffold` 的**外面**(与附件预览 / 编辑 / 删除那三个弹层
|
||||
* 同一处),拿不到宿主——而它的默认值是一个没人读的空列表,于是**不报错、也不显示**:
|
||||
* 分享进来会变成「什么都没发生」,正是本项目最忌讳的那种静默失败。(同批的 W53S 用的是
|
||||
* `OverlayDialog`,因为那个确认框就长在设置页的 `Scaffold` 里——两处调用点的处境不同。)
|
||||
* 2. 它与既有那四个弹层是**同一个形态**:独立窗口、自带遮罩、点面板外或按返回键即取消
|
||||
* (`onDismissRequest`,这里接的正是「取消」)。这里要的只是一次确认,不需要底部面板那套
|
||||
* 可拖拽的手势。
|
||||
*
|
||||
* ## 预览与输入框的分工(W53X3 更正:输入框是「用户附加的描述」,**不是**正文的复刻)
|
||||
* 上半是**预览**:这次分享进来的东西本身——图片缩略图 / 文件芯片 / **分享自带的正文**。那才是这次
|
||||
* 要发出去的内容,留着它用户才知道「她将收到的是什么」。
|
||||
* 下半是**输入框**:**你**要附加的描述,初值**恒为空**(`caption` 由调用方给空串),空框时显示
|
||||
* 占位符 [SHARE_CONFIRM_CAPTION_PLACEHOLDER](与输入栏那句「和昔涟说点什么…」同一个写法)。
|
||||
* 发送时真正发出去的正文由 [shareConfirmBody] 拼:分享正文(若有)+ 用户描述(若有),两段都在时
|
||||
* **空行分隔**,只有一段就只用那一段。
|
||||
*
|
||||
* ⚠️ 这里曾经把两件事搞成一件(W53X 的第一版):输入框被预填成分享进来的正文,在真机上就渲染成
|
||||
* 「同一句话排两遍」——预览的「分享的文字」块一遍、输入框里又一遍,而输入框里那句还看着像一段
|
||||
* 只读展示。错在「这次要发什么」与「你想补一句什么」本来是两件事:前者归预览,后者才归这个输入框。
|
||||
*/
|
||||
@Composable
|
||||
fun ShareConfirmPanel(
|
||||
share: SharedContent,
|
||||
caption: String,
|
||||
onCaptionChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
val preview = remember(share) { shareConfirmPreview(share) }
|
||||
|
||||
// `show = true` 恒成立:这个面板只在有内容可确认时被调用方组合出来(`shareConfirm?.let { … }`),
|
||||
// 关掉它靠的是调用方把那格状态清空(取消 / 发送后的清理都在那边)。
|
||||
WindowDialog(
|
||||
show = true,
|
||||
title = SHARE_CONFIRM_TITLE,
|
||||
onDismissRequest = onCancel,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
if (!preview.isEmpty) {
|
||||
ShareConfirmPreviews(preview)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
TextField(
|
||||
value = caption,
|
||||
onValueChange = onCaptionChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = SHARE_CONFIRM_CAPTION_PLACEHOLDER,
|
||||
useLabelAsPlaceholder = true,
|
||||
maxLines = 4,
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
// 「取消」放在「发送」之前(与编辑 / 删除那两个弹层同一个排法):右端永远是那枚
|
||||
// **会真的改动东西**的键。
|
||||
TextButton("取消", onClick = onCancel)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(
|
||||
text = "发送",
|
||||
onClick = onSend,
|
||||
// 什么都没有可发时这枚键不该可点:「发送」发出去的那段正文就是 [shareConfirmBody]
|
||||
// 拼出来的那一段,而 `sendShared` 在「正文空白 + 没有图片 + 没有文件」时会**原地
|
||||
// 返回**(一次发送都不会发生)。判据是纯函数 [shareConfirmCanSend],与那条早退同源。
|
||||
enabled = shareConfirmCanSend(share, caption),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次分享**该展示成哪几样东西**(纯数据,UI 照着渲染)。
|
||||
*
|
||||
* 与 [SharedContent] 不是同一个东西:[SharedContent] 是「用户分享了哪些字段」(还带着丢件计数、
|
||||
* 超限计数这些**告知**用的账),本类型只保留**看得见的那几样**。混用一个类型的话,渲染层迟早会去
|
||||
* 读那些计数字段并顺手显示出来,而它们该说的话由 `shareNoticeFor` 统一成句(它是唯一一处出处)。
|
||||
*/
|
||||
data class ShareConfirmPreview(
|
||||
/** 要显示的图片(本端 cache 里的 `file://` 地址,顺序即发送顺序)。 */
|
||||
val images: List<String>,
|
||||
/** 要显示的文件(显示名 + 体积由既有的 `DocumentChipRow` 负责换算)。 */
|
||||
val documents: List<SharedDocument>,
|
||||
/** 要显示的正文;空白已归一成空串。 */
|
||||
val text: String,
|
||||
) {
|
||||
/** 这份分享带不带正文(空串与「全空白」都算不带——只显示一行空气不是预览)。 */
|
||||
val hasText: Boolean get() = text.isNotEmpty()
|
||||
|
||||
/** 三样都没有 = 这次分享**没有可预览的内容**(此时预览区整块不出现)。 */
|
||||
val isEmpty: Boolean get() = images.isEmpty() && documents.isEmpty() && !hasText
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一份分享折算成要显示的那几样(**纯函数**,可单测)。
|
||||
*
|
||||
* 两处过滤是**空的也算没有**:一条空地址画出来就是一个空白缩略图(用户会以为图坏了),
|
||||
* 一段全空白的正文占着一行什么也不说。正文两侧的空白一并去掉,与
|
||||
* [top.yeij.cyrene.domain.share.ShareRules.extract] 取正文那一处同一个口径。
|
||||
*
|
||||
* 顺序原样保留:多张图的顺序**就是发送顺序**(第一条消息带第一张,见 `imageSendPlan`),
|
||||
* 预览里换一个顺序就是在替用户改他要发的东西。
|
||||
*/
|
||||
fun shareConfirmPreview(share: SharedContent): ShareConfirmPreview = ShareConfirmPreview(
|
||||
images = share.images.filter(String::isNotBlank),
|
||||
documents = share.documents.filter { it.uri.isNotBlank() },
|
||||
text = share.text.trim(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 点「发送」时**真正发出去的那段正文**(**纯函数**,可单测):分享自带的正文 + 用户附加的描述。
|
||||
*
|
||||
* 三段穷举(这也是它可测的全部形状):
|
||||
* - 两段都有 ⇒ `分享正文` + [SHARE_CONFIRM_BODY_SEPARATOR] + `用户描述`(**空行**分隔:两段话挨在
|
||||
* 一起时读者分不清哪句是谁说的;空行是「这是两段」的最小表达,也让正文里本来换行的人不受影响);
|
||||
* - 只有一段 ⇒ 就是那一段(**不补分隔符**:给一段话收个尾是凭空多出来的东西);
|
||||
* - 都空 ⇒ 空串(此时能发的只剩图片 / 文件,正文那一栏本来就该空着)。
|
||||
*
|
||||
* **内容一字不丢**是红线,这一条覆盖三件事:
|
||||
* 1. 分享正文按 [shareConfirmPreview] 的口径两侧去空白 —— 与 `ShareRules.extract` 取正文、
|
||||
* 以及 `sendShared` 开头 `share.text.trim()` 是**同一个**口径,所以「这里非空」≙「那边发得出去」;
|
||||
* 2. 用户描述同样只去两侧空白,**中间一个字不动**(用户自己换的行是他打的,不是我们排版的地方);
|
||||
* 3. 一律**不加**来源标记 —— 不拼「[用户分享]」、不加引号、不套模板。来源标识走后端的独立
|
||||
* `RoleSystem` 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`);把标记拼进正文
|
||||
* = 让用户正文冒充系统指令,那是项目红线。
|
||||
*
|
||||
* 顺序是「分享正文在前、用户描述在后」:先有「她收到的那份东西」,再有「你补的那句话」——反过来的
|
||||
* 话,用户想给一条链接加注释就得先读注释再读链接,而他要注释的正是那条链接。
|
||||
*/
|
||||
fun shareConfirmBody(share: SharedContent, caption: String): String {
|
||||
val shared = shareConfirmPreview(share).text
|
||||
val own = caption.trim()
|
||||
return when {
|
||||
shared.isEmpty() -> own
|
||||
own.isEmpty() -> shared
|
||||
else -> shared + SHARE_CONFIRM_BODY_SEPARATOR + own
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 两段正文之间的分隔(见 [shareConfirmBody]):**一个空行**(`\n\n`),不是空格、也不是一行 `——`。
|
||||
*
|
||||
* 只在**两段都在**时出现:`\n\n` 是任何聊天客户端都认的「两段」写法,而多出来的装饰性分隔行
|
||||
* (`---`、`· · ·`)会被对面当成内容读进去。
|
||||
*/
|
||||
const val SHARE_CONFIRM_BODY_SEPARATOR = "\n\n"
|
||||
|
||||
/**
|
||||
* 面板里那枚「发送」该不该可点(**纯函数**,可单测)。
|
||||
*
|
||||
* 判据与 `ChatViewModel.sendShared` 开头那条早退**是同一条**:那边算的是
|
||||
* `share.text.trim()`(也就是本端 [shareConfirmBody] 会拼进去的那一段)非空、或图片非空、或文件非空;
|
||||
* 三样都空时它原地 `return`——一次发送都不会发生。此时把键画成可点的,用户按下去只会看到面板关掉、
|
||||
* 什么也没发生,而他会以为自己少发了一条。所以这里回 `false`。
|
||||
*
|
||||
* 正文那一支直接问 [shareConfirmBody](**同一个**出处,不是另写一遍 `text + caption` 的非空判断):
|
||||
* 两条各写各的判据迟早会分叉,那时按钮会在「真发得出去」的时候变灰,或者反过来。
|
||||
*
|
||||
* ⚠️ 三样里**任意一样非空就可发**:纯图片 / 纯文件的回合服务端**支持**(契约:`text` / `image` /
|
||||
* `documents` 任一项非空即可),本端也照旧只发得出去的那几样,不因为「没写文字」而拦下它。
|
||||
*/
|
||||
fun shareConfirmCanSend(share: SharedContent, caption: String): Boolean {
|
||||
val preview = shareConfirmPreview(share)
|
||||
return shareConfirmBody(share, caption).isNotEmpty() ||
|
||||
preview.images.isNotEmpty() ||
|
||||
preview.documents.isNotEmpty()
|
||||
}
|
||||
|
||||
/** 面板标题(与顶栏那句「与昔涟」同一称呼)。 */
|
||||
const val SHARE_CONFIRM_TITLE = "发送给昔涟"
|
||||
|
||||
/**
|
||||
* 输入框的占位符(`useLabelAsPlaceholder`:**只在框里没字时**显示)。
|
||||
*
|
||||
* 与输入栏那句「和昔涟说点什么…」同一个写法、同一个语气:它说的是**你**要补一句什么。
|
||||
* 框里的字是用户自己打的、默认没有(初值恒为空)——所以这句占位符是常态而不是罕见档。
|
||||
*/
|
||||
const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
|
||||
|
||||
/**
|
||||
* 预览区(图片 / 文件 / 正文)。
|
||||
*
|
||||
* ## 高度上限与内部滚动
|
||||
* Miuix 那两族弹层的 `content` **不是 `ColumnScope`**(是普通的 `@Composable () -> Unit`),
|
||||
* 所以这里用不了 `Modifier.weight(1f)`(那是 `ColumnScope` 的扩展)——预览区没法「占剩下的高度」。
|
||||
* 极端的一次分享(4 张图 + 5 份文件 + 一段长正文,键盘还弹着)会把这一个面板顶得很高,
|
||||
* 而面板在手机上贴着屏幕底(`BottomCenter`):超出的部分会把下面的输入框与两枚按钮挤出屏幕。
|
||||
* 所以预览区自己带一个高度上限 + 内部滚动:内容少时它按内容高(上限不生效),内容多时它滚动、
|
||||
* 输入框与按钮原地不动。上限按状态取([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]),面板不随正文长短
|
||||
* 变化。
|
||||
*
|
||||
* 但三行之外**不再收成省略号**(W54D 主人拍板改掉了 W53X 的老行为):W53X3 之后**面板里再没有第二
|
||||
* 个地方放着这段正文**——输入框是「用户附加的描述」(初值恒为空),不再是正文的可编辑副本。于是截断
|
||||
* 成省略号就等于「按发送之前读不到要发的那段正文」,而用户点这颗「发送」要确认的恰恰是它。所以这一窗
|
||||
* 内部可滚([ShareConfirmBody]):**面板的高度一分不涨,正文滚着读完**。
|
||||
*
|
||||
* ## 三行读不下时,再给一次「展开」
|
||||
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;主人要的是**也能一次多看几行**
|
||||
* (W56 拍板:滚动与展开**两个都要**)。所以这一窗**装不下**里面那几块时(判据是
|
||||
* [sharePreviewTextOverflowsWindow]:量出来的内容自然高度比窗口高,正好放满不给)卡片右上一枚
|
||||
* 「展开」把这一窗放到 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。装得下时这一枚
|
||||
* **不出现**——短正文上摆一枚点了没反应的「展开」比没有更糟。
|
||||
*
|
||||
* 「读不到尾」这条红线在两种状态下都守:收起态窗内滚,展开态由外层这一层滚(正文那一层不再设上限,
|
||||
* 见 [ShareConfirmBody] 的「展开态为什么干脆不留这一层上限」)。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
|
||||
// 展开是用户对**这一段正文**的选择,键在 `preview.text` 上:换一份分享就回到默认的收起,同一份
|
||||
// 分享里重组(写描述、列表刷新…)保持用户选过的样子。
|
||||
//
|
||||
// ⚠️ 转屏(Activity 重建)会回到收起:跨转屏要活下来就得进 `ChatUiState`——W54C 给「已取走的
|
||||
// 分享」与描述走的就是那条路,而那是 `ChatScreen` / `ChatViewModel` 的地盘(本批不许碰)。
|
||||
// `rememberSaveable` 是本仓一次都没用过的新写法,而且弹层窗口有没有带上 saveable registry 没在
|
||||
// 真机上验过,不在这里试——如实留给主人定。
|
||||
var textExpanded by remember(preview.text) { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = sharePreviewMaxHeight(textExpanded))
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (preview.images.isNotEmpty()) {
|
||||
// 与输入栏上方那排待发图片预览同一形态(同样 64dp、同样的圆角与间距),只是**没有**
|
||||
// 逐张移除的叉:反悔的粒度是整份分享(「取消」),这里不另开一条「只丢掉其中一张」的路
|
||||
// ——那条路要么改动待发队列、要么改动已取走的那一份,两种都会被「又来了新分享」搅浑。
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
preview.images.forEach { uri ->
|
||||
AsyncImage(
|
||||
model = uri,
|
||||
contentDescription = "这次分享的图片",
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (preview.documents.isNotEmpty()) {
|
||||
// 文件芯片就是气泡里那一排(显示名 + 体积),一枚都不藏:上限 5 份会占两到三行。
|
||||
DocumentChipRow(chips = preview.documents.map { DocumentChips.of(it.toDraftDocument()) })
|
||||
}
|
||||
if (preview.hasText) {
|
||||
// 「装不装得下」只有排完才知道(折行位置由字号与系统的大字体缩放决定,块数与旁白行数更是
|
||||
// 解析出来的),所以它由正文那一窗量出来之后回调上来,标签这一行凭它决定要不要给出控件。
|
||||
var bodyOverflows by remember(preview.text) { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MiuixTheme.colorScheme.surfaceContainer)
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "分享的文字",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
// 标签是这一行唯一的权重子项,`fill = false` 让这五个字只占自己需要的宽度;
|
||||
// 真正保证展开控件不被挤掉的是 Row 的测量顺序——**非权重子项(控件与它前面
|
||||
// 的间距)先量**,权重只分配剩下的空间(同 `SessionDrawerContent` 里那一行)。
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (bodyOverflows) {
|
||||
// 控件挂在**标签这一行**,不挂在正文末尾:展开后正文可能有好几屏,末尾那枚
|
||||
// 「收起」就成了要滚到底才找得到的东西。
|
||||
Spacer(Modifier.width(8.dp))
|
||||
ShareConfirmExpandToggle(
|
||||
expanded = textExpanded,
|
||||
onToggle = { textExpanded = !textExpanded },
|
||||
)
|
||||
}
|
||||
}
|
||||
ShareConfirmBody(
|
||||
text = preview.text,
|
||||
expanded = textExpanded,
|
||||
onOverflowChange = { bodyOverflows = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「展开 / 收起」那一枚(见 [ShareConfirmPreviews])。
|
||||
*
|
||||
* 为什么是两个字而不是箭头图标:基础图标那一套(`MiuixIcons.Basic.*`)里没有展开 / 收起这一类字形
|
||||
* (只有 `Close` / `Search` / `SearchCleanup` / `Sidebar` / `Check` / `ArrowRight` / `ArrowUpDown`
|
||||
* 七枚),而这两句话本身就说清了点下去会怎样。另一处折叠入口——`AdminOverviewScreen` 的路径组头
|
||||
* ——用的是扩展图标里的 `ChevronForward` 加旋转,那是列表行里的形态;这里是一张阅读用的卡片,
|
||||
* 一句话更省地方,也更好认。
|
||||
*
|
||||
* 用 `Text` + `clickable` 而不是 `TextButton`:后者自带按钮高度,会把这一行(以及整块预览)撑高
|
||||
* 一截——同 `MessageBubble` 里那枚「更多」的取舍。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
|
||||
Text(
|
||||
text = if (expanded) SHARE_PREVIEW_COLLAPSE_LABEL else SHARE_PREVIEW_EXPAND_LABEL,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onToggle)
|
||||
// 内边距写在 `clickable` **后面**:命中的就是这块带内边距的区域——只包住字身的触点太小。
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** 相邻两个气泡块之间的间距(见 [ShareConfirmBody])。块与块靠这一层间距分开,不靠气泡底色。 */
|
||||
private val SHARE_PREVIEW_BLOCK_GAP = 6.dp
|
||||
|
||||
/** 一个块里旁白行与正文行之间的间距(气泡里那两行也挨得很近,与 `AnnotationLine` 的 2dp 同源)。 */
|
||||
private val SHARE_PREVIEW_LINE_GAP = 2.dp
|
||||
|
||||
/**
|
||||
* 正文那一窗:**高度恒为三行、再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews]);
|
||||
* 用户点了「展开」之后这一窗撤掉自己的上限,全部内容交给外层预览区那一层滚(W56)。
|
||||
*
|
||||
* 里面排的**不是一段原样的字**,而是 [sharePreviewBlocks] 给出的那几个「气泡块」(W65 起走与
|
||||
* `MessageBubble` 同一个 [parse])。每个块自己排旁白行与正文行,块与块之间留
|
||||
* [SHARE_PREVIEW_BLOCK_GAP]。
|
||||
*
|
||||
* ## 为什么量行高,而不是写一个 dp 常量
|
||||
* 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」
|
||||
* 这个上限悄悄放宽。而三行有多高没法提前写出来——Miuix 的 `footnote1` 只给了 13sp 的字号,
|
||||
* `lineHeight` 是 `Unspecified`,行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。
|
||||
* 一个 dp 常量在默认字号档上勉强对得上,到了大字号档就会矮一大截(行数不足三行、末行被切)。
|
||||
* 所以这里改为**量**:任一排版结果都能给出「一行多高」(`getLineBottom(0) - getLineTop(0)`),
|
||||
* 窗口高度 = 它 × [SHARE_PREVIEW_TEXT_LINES]([sharePreviewWindowHeight])。
|
||||
*
|
||||
* **为什么不再取「第三行的底边」**(那是 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` 记的
|
||||
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,两种状态下排出来的行高一样)。
|
||||
*
|
||||
* ## 展开态为什么干脆不留这一层上限
|
||||
* 展开之后正文可能几屏长。若在这一窗上再设一个上限,就成了「窗外滚 + 窗内滚」两层都要滚的手势
|
||||
* (手指先被内层吃掉、内层滚到底才轮到外层),而两层的上界还互相牵制——最容易出的错就是「明明展开
|
||||
* 了、还剩半截读不到」。所以展开态**只留外层那一层**:预览区的上限抬到
|
||||
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],整段正文摊在里面,一层滚到底,读不读得到尾只取决于那一层。
|
||||
* 代价是展开后不能「只滚正文、图片与文件钉着不动」——那要把两层滚动接起来,这个面板不值这个复杂度。
|
||||
*
|
||||
* ## 首帧的那次回流:留着,且确认它看不见
|
||||
* 上限是**首帧之后**才量到的,所以长正文会先按 fallback([SHARE_PREVIEW_MAX_HEIGHT])排一次,
|
||||
* 下一帧才收到三行。真机上**看不见**跳动,三条理由:
|
||||
* 1. 首帧那一下撑不动任何别的东西——正文块是预览区里的**最后一个**元素(图片、文件都在它上面),
|
||||
* 而预览区自己还有一层独立的上限在管可视高度:下面没有东西被它顶下去,上面那两样的位置一分
|
||||
* 不动,可视的像素与量完之后**一样**(那些像素由 `heightIn` 的裁剪决定,裁剪高度不变)。
|
||||
* 2. 展开态本来就没有这一层上限,不存在「量完再回落」;而展开 / 收起是用户主动的一次布局变化,
|
||||
* 那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的是展开控件本身——它要到第二帧才出现
|
||||
* (自然高度是排完才知道的),但这一枚也顶不动东西:标签行里那两句话用的是同一个 `footnote2`,
|
||||
* 行高不变。
|
||||
* 3. 首帧那一次比出来的是「自然高度 vs 150dp 兜底」而不是「vs 三行」:那一帧的结论**可能是错的**
|
||||
* (一段四五行的正文在 150dp 下看着「装得下」),但它在下一帧就被纠正,而这一枚控件本来就要到
|
||||
* 第二帧才可能摆上——所以这条错判永远看不见。
|
||||
*
|
||||
* 另一条路是在组合期用 `rememberTextMeasurer` 把上限量出来,好让控件从首帧就摆上:那要先知道宽度
|
||||
* (得外包一层 `BoxWithConstraints`),还要自己复现字号、字体缩放与字体回退,等于把排版做两遍——
|
||||
* 为一个看不见的跳动换掉结构不划算,所以不做。
|
||||
*
|
||||
* ## 两层滚动怎么分工
|
||||
* 收起态:这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块
|
||||
* 合起来的高度上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来
|
||||
* 就走嵌套滚动,不用额外接线。
|
||||
*
|
||||
* ⚠️ **展开态必须把这一层整个去掉(上限 + 滚动一起),只留外层那一层滚。** 只去掉上限是不够的 ——
|
||||
* 外层 [ShareConfirmPreviews] 自己就是一个 `verticalScroll`,它给内容的是**无界高度**;这一层若
|
||||
* 留着 `verticalScroll`,那个 scroll 就会在无界高度下被测量 ⇒ 抛同一个
|
||||
* `IllegalStateException`(W58 真机在「点展开」的那一下崩了第二次,就是这么来的)。
|
||||
* 展开后读不读得到尾,只取决于外层那一层。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmBody(
|
||||
text: String,
|
||||
expanded: Boolean,
|
||||
onOverflowChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
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()
|
||||
.then(window)
|
||||
.onSizeChanged { contentHeight = it.height },
|
||||
verticalArrangement = Arrangement.spacedBy(SHARE_PREVIEW_BLOCK_GAP),
|
||||
) {
|
||||
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
|
||||
|
||||
/**
|
||||
* 预览区在**展开态**的高度上限(用户点了「展开」之后,见 [ShareConfirmPreviews])。
|
||||
*
|
||||
* 为什么要抬:展开是**用户自己的一个动作**,要的就是「一次多看几行」。上限不动的话这一窗永远只有
|
||||
* 三行高,点开等于没点——正文那一窗顶到 [SHARE_PREVIEW_MAX_HEIGHT] 也只是把三行换成十来行里的一
|
||||
* 小截。所以展开态必须真的给回高度。
|
||||
*
|
||||
* 为什么是 240dp 而不是更大:这个上限管的是**整个预览区**,而它下面还压着描述输入框(最多 4 行)
|
||||
* 与两枚按钮,面板又贴着屏幕底(`BottomCenter`)、键盘一弹可用高度只剩半屏上下。150 → 240 在默认
|
||||
* 字号档上把正文窗从三行抬到十行左右(`footnote1` 13sp,行高由系统字体决定、随机型与大字体设置一起
|
||||
* 变),加上标题 / 输入框 / 按钮之后整块仍在半屏出头;再往上(例如给到整屏的六成)先被顶掉的会是
|
||||
* 两枚按钮。
|
||||
*
|
||||
* ⚠️ 这个数**没有在真机上量过**,是按上面那笔账取的保守值,观感由主人验收。要调只改这一处:
|
||||
* 预览区那一层的高度上限取的就是 [sharePreviewMaxHeight] 这一个出口。
|
||||
*/
|
||||
internal val SHARE_PREVIEW_EXPANDED_MAX_HEIGHT = 240.dp
|
||||
|
||||
/**
|
||||
* 预览里正文那一窗给几行(W54D 起**不再截断**,超出的部分在窗内滚动,见 [ShareConfirmBody])。
|
||||
*
|
||||
* 它同时是窗口高度那笔账里唯一的数:窗口高 = **量到的行高** × 这个数([sharePreviewWindowHeight])。
|
||||
*
|
||||
* `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有
|
||||
* Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。
|
||||
*/
|
||||
internal const val SHARE_PREVIEW_TEXT_LINES = 3
|
||||
|
||||
/**
|
||||
* 展开控件上那两句话:点它就在这两句之间切换。
|
||||
*
|
||||
* 两句都必须是**动作**(「展开」/「收起」),不能是「更多」/「更少」——后者说的是内容有多少,读的人
|
||||
* 不知道点下去会发生什么。
|
||||
*/
|
||||
internal const val SHARE_PREVIEW_EXPAND_LABEL = "展开"
|
||||
internal const val SHARE_PREVIEW_COLLAPSE_LABEL = "收起"
|
||||
|
||||
/**
|
||||
* 三行窗口有多高(**纯函数**,可单测):量到的一行高度 × [SHARE_PREVIEW_TEXT_LINES]。
|
||||
*
|
||||
* 行高只能是**量**出来的(见 [ShareConfirmBody] 的「为什么量行高,而不是写一个 dp 常量」),所以这里
|
||||
* 只做乘法、不掺任何 dp 常量:大字体档下量到的行高更大,窗口就跟着更高,「三行」始终是三行。把这个数
|
||||
* 留在唯一一处,改它只改 [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]、展开态
|
||||
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT]。
|
||||
*
|
||||
* 把「哪个状态用哪个上限」收在这一处,调用点([ShareConfirmPreviews])只读结论:单测钉住的就是这条
|
||||
* 判据本身——两个状态与两个上限的对应关系要是被改反了(例如展开反而变矮),会在这里被抓住。
|
||||
*/
|
||||
internal fun sharePreviewMaxHeight(expanded: Boolean): Dp =
|
||||
if (expanded) SHARE_PREVIEW_EXPANDED_MAX_HEIGHT else SHARE_PREVIEW_MAX_HEIGHT
|
||||
|
||||
/**
|
||||
* 分享进来的一份文件 ⇒ 待发附件。
|
||||
*
|
||||
* `ChatViewModel.sendShared` 里也有同一段三字段的搬运(那边是发给上传链的),**不抽公共**是刻意的:
|
||||
* 抽出来就要动那两个文件(一个是别的并行改动正在碰的 `ChatScreen` 附近,一个是发送路径),
|
||||
* 而这一处只是给芯片显示用的**只读**形态——真正的发送仍然只经过 `sendShared` 那一条,
|
||||
* 两条路各有各的用处。第三次复制出现时再抽(本项目对重复的硬规矩是三次起抽)。
|
||||
*/
|
||||
private fun SharedDocument.toDraftDocument(): DraftDocument = DraftDocument(
|
||||
uri = uri,
|
||||
displayName = displayName,
|
||||
sizeBytes = sizeBytes,
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
|
||||
/** 「分享进来的内容」这一刻该做什么([shareLanding] 的取值)。 */
|
||||
enum class ShareLanding {
|
||||
/** 没有待发内容:什么都不做(任何连接状态下都一样)。 */
|
||||
Nothing,
|
||||
|
||||
/** 还没连上,继续等:**既不取走也不发送**,内容原封不动留在待发队列里。 */
|
||||
KeepWaiting,
|
||||
|
||||
/** 等到该说一句的时候了:说一次「还没连上、连上后自动发出」(柔和通道,不是错误横幅)。 */
|
||||
TellWaiting,
|
||||
|
||||
/**
|
||||
* 等到该说一句的时候了,而这一刻的未就绪是**不可恢复**的那一档([WsState.Offline]):
|
||||
* 说一次 [SHARE_OFFLINE_NOTICE]——说清「需要重新登录或检查服务器地址」,**不**说
|
||||
* 「连上后自动发出」(那句话在 `Offline` 上永远不会到来)。
|
||||
*/
|
||||
TellOffline,
|
||||
|
||||
/** 连接就绪:取走一条并交给确认面板(点「发送」才真的发出去)。 */
|
||||
Send,
|
||||
}
|
||||
|
||||
/**
|
||||
* 分享落地时机的判据(纯函数,UI 与测试共用)。
|
||||
*
|
||||
* ## 为什么取走必须晚于「连接就绪」
|
||||
* 短文本走 WS `chat` 帧,而**分享冷启动必然赶上握手还没完成**:`ChatSocket` 未就绪时
|
||||
* `sendChat` 回 `false`,`ChatViewModel.deliver` 随即 `rollback(localId, "发送失败,请重试")`。
|
||||
* 2026-10-05 11:56 实机取证:客户端在 `GET /ws/chat status=200`(握手成功)前后的窗口里投递分享,
|
||||
* 得到「发送失败,请重试」+ 正文退回输入栏,而**后端零请求**(`POST /api/v1/chat` 一条都没有,
|
||||
* 也没有任何 `error` 帧)——那条横幅把「一次都没尝试发出去」说成了「发送失败」。
|
||||
*
|
||||
* 更糟的是它会**吃掉重试资格**:[top.yeij.cyrene.domain.share.PendingShareHolder.take] 是原子出队、
|
||||
* **没有归还路径**。失败之后内容既不在待发队列里、也不在服务端,只剩回滚进输入栏的那一份
|
||||
* (多图分享里除了第一张,其余几条连输入栏都回不去)。所以在连接就绪之前**一个字节都别动**:
|
||||
* 不 `take()`、不 `sendShared`、不产生任何 error 横幅——**超时绝不等于发送失败**。
|
||||
*
|
||||
* ## 判据与优先级
|
||||
* 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。
|
||||
* 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并交给确认面板(点「发送」才真的发)。
|
||||
* 它**压过**后面每一条——连接一到就该认领,哪怕此前已经等够久、那句「等连上会自动发」也说过了。
|
||||
* 返回值是**单值动作**,所以「认领」与「告知」在类型上就不可能同时发生(不会既取了又报怨还没连上)。
|
||||
* 3. 未就绪且**还没等够久**(`waitedLong = false`)⇒ [ShareLanding.KeepWaiting]:继续等,什么都不说
|
||||
* (刚分享进来就弹一句「还没连上」是噪声,握手正常也就几百毫秒)。
|
||||
* 4. 未就绪、已等够久、且**还没说过** ⇒ 说一次。说哪一句由连接状态分**两档**:可恢复的那三档
|
||||
* ([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])是 [ShareLanding.TellWaiting],
|
||||
* 不可恢复的 [WsState.Offline] 是 [ShareLanding.TellOffline](哪句配哪档见 [shareWaitingNotice])。
|
||||
* 两档**只有措辞不同**:第 3、5 条对它们一模一样(等够久才说、同一轮只说一次),取走与发送的时机
|
||||
* 一个字都没改。
|
||||
* 5. 未就绪、已等够久、且**已经说过** ⇒ [ShareLanding.KeepWaiting]:同一轮等待只说一次。
|
||||
* 这一步不是可有可无的:连接在 `Connecting` 与 `Reconnecting` 之间抖动会让调用方反复问到
|
||||
* 同一个问题,没有这一条就是反复弹同一句话。
|
||||
*
|
||||
* ## 为什么 `Offline` 要单独一档
|
||||
* [WsState.Offline] 是**终态**:握手 401 且续不上、或缺少 token / 服务器地址,它**不会自动重连**。
|
||||
* 对它说「连上后自动发出」,用户等的是一个**永远不会到来**的连接——那是拿一句不会兑现的承诺糊住
|
||||
* 一个需要他动手的状况,比不说更糟。这一档仍要告知(内容确实一个字节都没发出去,用户得知道,
|
||||
* 否则会以为东西丢了、重新分享一遍),但要说他**能做的事**:重新登录或检查服务器地址。
|
||||
* 这里没有发明任何新的状态语义:`Offline` 是 [WsState] 自己的一档,`ChatScreen` 的 `connectionLabel`
|
||||
* 早就把它在界面上单独念成「离线」,也正是那一档的分野(会不会自己重连)。
|
||||
*
|
||||
* ## 「等够久」与「说过了」为什么不在这里判
|
||||
* 判据本身**看不出时间与历史**——那样它才是一个能穷举、能在 JVM 上单测的函数(见 `ShareLandingTest`),
|
||||
* 而不是又一份会随重组漂移的 Compose 状态。两个事实由调用方各自记账(`ChatScreen` 里那两个
|
||||
* `remember`),且**它们的生命周期也不一样**:「说过了」在连接抖动时不归零(不刷屏),
|
||||
* 而「等够久」的计时只看「队列里有没有东西」与「连上了没有」这两个事实。
|
||||
*
|
||||
* @param hasPending 待发队列里有没有内容。只看一眼的判据是 `ShareIntake.pending`(StateFlow),
|
||||
* **绝不是 `take()`**——那是出队。
|
||||
* @param connection 这一刻的 WS 连接状态。
|
||||
* @param waitedLong 这一轮等待是否已经等够久(够久才值得说那句话)。
|
||||
* @param alreadyTold 这一轮等待是否已经告知过(同一轮只说一次)。
|
||||
*/
|
||||
fun shareLanding(
|
||||
hasPending: Boolean,
|
||||
connection: WsState,
|
||||
waitedLong: Boolean,
|
||||
alreadyTold: Boolean,
|
||||
): ShareLanding = when {
|
||||
!hasPending -> ShareLanding.Nothing
|
||||
connection == WsState.Connected -> ShareLanding.Send
|
||||
!waitedLong -> ShareLanding.KeepWaiting
|
||||
alreadyTold -> ShareLanding.KeepWaiting
|
||||
// 只有 Offline 这一档不会自己回来(见上面「为什么 Offline 要单独一档」)。
|
||||
connection == WsState.Offline -> ShareLanding.TellOffline
|
||||
else -> ShareLanding.TellWaiting
|
||||
}
|
||||
|
||||
/**
|
||||
* 告知这一档该说的那句话(**纯函数**;`null` = 这一刻什么都不该说)。
|
||||
*
|
||||
* 把「哪个动作配哪句话」也做成可单测的,是因为两条告知**唯一**的差别就是措辞,而搞混它们的代价
|
||||
* 并不对称:对 `Offline` 说「连上后自动发出」是承诺一件不会发生的事(那句文案永远不会兑现),
|
||||
* 对 `Connecting` / `Reconnecting` 说「重新登录」则是把一次几百毫秒的握手说成了故障。
|
||||
* 两句话各自写在 `ChatScreen` 的 [SHARE_NOT_CONNECTED_NOTICE] / [SHARE_OFFLINE_NOTICE] 旁边,
|
||||
* 这里只做映射——于是「哪档说哪句」不需要组合一个界面就能测(见 `ShareLandingTest`)。
|
||||
*
|
||||
* [ShareLanding.Send] / [ShareLanding.Nothing] / [ShareLanding.KeepWaiting] 都回 `null`:
|
||||
* 它们不是告知档(要么发送、要么什么都不说)。
|
||||
*/
|
||||
fun shareWaitingNotice(landing: ShareLanding): String? = when (landing) {
|
||||
ShareLanding.TellWaiting -> SHARE_NOT_CONNECTED_NOTICE
|
||||
ShareLanding.TellOffline -> SHARE_OFFLINE_NOTICE
|
||||
ShareLanding.Nothing, ShareLanding.KeepWaiting, ShareLanding.Send -> null
|
||||
}
|
||||
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user