Compare commits
229
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4834905b24 | ||
|
|
217cfe3d5f | ||
|
|
ae4e4e53f6 | ||
|
|
664fd8dcef | ||
|
|
b44e8d96b9 | ||
|
|
51ea890684 | ||
|
|
f413f8db55 | ||
|
|
cc2f266564 | ||
|
|
b7806951eb | ||
|
|
feeb49ebeb | ||
|
|
e642b0c18c | ||
|
|
4c14028c70 | ||
|
|
d12a920096 | ||
|
|
2a49c25e1b | ||
|
|
c09a21136c | ||
|
|
f5576540b4 | ||
|
|
09a3222b7b | ||
|
|
9f054aad28 | ||
|
|
acff289887 | ||
|
|
a7b3452e6d | ||
|
|
9bc45dc315 | ||
|
|
664fb2d9ed | ||
|
|
cf291b58ab | ||
|
|
df2a26701c | ||
|
|
00856eeaf7 | ||
|
|
61214a18fa | ||
|
|
91117986e4 | ||
|
|
33770d87a6 | ||
|
|
0ea41f5815 | ||
|
|
5a5b02f6fb | ||
|
|
5975827aea | ||
|
|
7faa604fd0 | ||
|
|
5ac0ea3c3b | ||
|
|
5d71c6a2ab | ||
|
|
e72621db94 | ||
|
|
e04995a84d | ||
|
|
67ed0479e4 | ||
|
|
a8a422c4e1 | ||
|
|
6323f89b5b | ||
|
|
01b5f980a5 | ||
|
|
63fe9a12a6 | ||
|
|
171f6ff59a | ||
|
|
0587f5a45f | ||
|
|
253913ad82 | ||
|
|
84de51c7b7 |
No files matched your search
+8
-29
@@ -1,35 +1,14 @@
|
||||
# ---> Android
|
||||
# Gradle files
|
||||
*.iml
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
.kotlin/
|
||||
# 本机开发凭据(主人授权落地的测试用 key 等)——只在本机,绝不进仓库
|
||||
dev-secrets.local.md
|
||||
local.properties
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
|
||||
# Android Studio generated files and folders
|
||||
.idea/
|
||||
.DS_Store
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
output.json
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# 主人从手机丢进仓库根当素材用的图(例如换图标用的原图)——只用不存,绝不进仓库
|
||||
/IMG_*.png
|
||||
@@ -1,73 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright 2026 AskaEth
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,78 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "top.yeij.cyrene"
|
||||
compileSdk = 37
|
||||
buildToolsVersion = "37.0.0"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "top.yeij.cyrene"
|
||||
minSdk = 33
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
|
||||
implementation(libs.miuix.ui)
|
||||
implementation(libs.miuix.icons)
|
||||
implementation(libs.miuix.preference)
|
||||
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.kotlinx.serialization)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.**
|
||||
|
||||
-keepclassmembers class **$$serializer {
|
||||
*** descriptor;
|
||||
}
|
||||
-keepclasseswithmembers class ** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:name=".app.CyreneApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Cyrene">
|
||||
|
||||
<!--
|
||||
launchMode="singleTop":App 已经开着时从别的 App 分享过来,系统不再新建一个实例,
|
||||
改成回调 onNewIntent(见 MainActivity)。不写它就会起第二个 Activity 实例、
|
||||
两个实例各持一套 ViewModel / socket。
|
||||
-->
|
||||
<activity
|
||||
android:name=".app.MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!--
|
||||
分享入口:只声明本端真能落地的两类 MIME(纯文本 / 图片)。
|
||||
刻意不声明 */*——对话路径上今天没有「文件」这个表示(协同文档 02/F1 的三缺),
|
||||
声明了就等于给出一个点进去必然失败的入口。标签沿用应用名(活动不设自己的
|
||||
label / icon 时,分享菜单取的就是 Cyrene 的名字与启动图标)。
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- 多图分享只收图片:多条纯文本的 EXTRA_TEXT(SEND_MULTIPLE + text/plain)本端不接。 -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</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>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,247 @@
|
||||
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.prefs.AppSettings
|
||||
import top.yeij.cyrene.core.prefs.EnumPreferences
|
||||
import top.yeij.cyrene.core.prefs.SessionPreferences
|
||||
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.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.repository.AdminRepository
|
||||
import top.yeij.cyrene.data.repository.ChatRepository
|
||||
import top.yeij.cyrene.data.repository.DocumentRepository
|
||||
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
|
||||
|
||||
/** 手写 DI 容器(D:不引 Hilt)。 */
|
||||
class AppContainer(context: Context) {
|
||||
|
||||
/** 应用上下文:给需要在 ViewModel 里读本地资源(图片解码等)的组件用。 */
|
||||
val appContext: Context = context.applicationContext
|
||||
|
||||
val sessionStore: SessionStore = SessionStore(context.applicationContext)
|
||||
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
|
||||
val enumPreferences: EnumPreferences = EnumPreferences(context.applicationContext)
|
||||
val appSettings: AppSettings = AppSettings(context.applicationContext)
|
||||
val authManager: AuthManager = AuthManager(sessionStore)
|
||||
|
||||
/**
|
||||
* 本端已知「服务端没有这个会话」的那几个 id(**进程级**,与 [chatSocket] 同一层)。
|
||||
*
|
||||
* 跨两个 VM 的唯一共享面:写它的是 `SessionsViewModel`(本端唯一的删除入口),读它的是
|
||||
* **每一个** `ChatViewModel`(它们各留一个常驻 VM,删会话不回收)。它拦下的是那一轮注定
|
||||
* `404 session_not_found` 的补拉 —— 理由与「为什么不持久化」见 [GoneSessions] 的类注释。
|
||||
*/
|
||||
val goneSessions: GoneSessions = GoneSessions()
|
||||
|
||||
private var cachedBaseUrl: String? = null
|
||||
private var cachedSessionsApi: SessionsApi? = null
|
||||
|
||||
/** 会话/消息 REST 客户端;baseUrl 变化时自动重建。 */
|
||||
val sessionsApi: SessionsApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedSessionsApi
|
||||
if (cached != null && cachedBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = SessionsApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedBaseUrl = baseUrl
|
||||
cachedSessionsApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val sessionRepository: SessionRepository get() = SessionRepository(sessionsApi)
|
||||
|
||||
val messageRepository: MessageRepository get() = MessageRepository(sessionsApi)
|
||||
|
||||
private var cachedChatApi: ChatApi? = null
|
||||
private var cachedChatBaseUrl: String? = null
|
||||
|
||||
/** 发送端点(`POST /api/v1/chat`);与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val chatApi: ChatApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedChatApi
|
||||
if (cached != null && cachedChatBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = ChatApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedChatBaseUrl = baseUrl
|
||||
cachedChatApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val chatRepository: ChatRepository get() = ChatRepository(chatApi)
|
||||
|
||||
private var cachedUploadApi: UploadApi? = null
|
||||
private var cachedUploadBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 图片上传端点(`POST /api/v1/upload`);与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理)——
|
||||
* 上传的产物 URL 就是要交给 `POST /api/v1/chat` 的 `image` 字段的,两者必须在同一个后端上。
|
||||
*/
|
||||
val uploadApi: UploadApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedUploadApi
|
||||
if (cached != null && cachedUploadBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = UploadApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedUploadBaseUrl = baseUrl
|
||||
cachedUploadApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
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 cachedAdminApi: AdminApi? = null
|
||||
private var cachedAdminBaseUrl: String? = null
|
||||
|
||||
/** 管理只读监控客户端;与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val adminApi: AdminApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedAdminApi
|
||||
if (cached != null && cachedAdminBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = AdminApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedAdminBaseUrl = baseUrl
|
||||
cachedAdminApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val adminRepository: AdminRepository get() = AdminRepository(adminApi)
|
||||
|
||||
private var cachedMetaApi: MetaApi? = null
|
||||
private var cachedMetaBaseUrl: String? = null
|
||||
|
||||
/** 枚举权威表客户端;同样与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val metaApi: MetaApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedMetaApi
|
||||
if (cached != null && cachedMetaBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = MetaApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedMetaBaseUrl = baseUrl
|
||||
cachedMetaApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val metaRepository: MetaRepository get() = MetaRepository(metaApi)
|
||||
|
||||
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`;而 VM 活在 Activity 级
|
||||
* `ViewModelStore` 里,**切走 / 删会话都不回收**。现在这里就是唯一的持有者(`by lazy`,只此一份),
|
||||
* 调用方只订阅 [ChatSocket.events] / [ChatSocket.state]。
|
||||
*
|
||||
* ## 开与关只有一个主人
|
||||
* [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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.ui.chat.ChatHomeScreen
|
||||
import top.yeij.cyrene.ui.common.LoadingScreen
|
||||
import top.yeij.cyrene.ui.login.LoginScreen
|
||||
|
||||
@Composable
|
||||
fun CyreneApp(container: AppContainer) {
|
||||
val state by container.authManager.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
container.authManager.bootstrap()
|
||||
}
|
||||
|
||||
when (state) {
|
||||
SessionState.Unknown -> LoadingScreen()
|
||||
SessionState.LoggedOut -> LoginScreen(container)
|
||||
is SessionState.LoggedIn -> ChatHomeScreen(
|
||||
container = container,
|
||||
// 登出要先把服务端的 refresh 授权吊销掉,所以是挂起的;UI 不因为它在主线程等待。
|
||||
onLogout = { scope.launch { container.authManager.logout() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class CyreneApplication : Application() {
|
||||
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import top.yeij.cyrene.core.prefs.AppPrefs
|
||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
||||
import top.yeij.cyrene.core.prefs.ThemeMode
|
||||
import top.yeij.cyrene.core.theme.CyreneTheme
|
||||
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
|
||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||
|
||||
/**
|
||||
* 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] / [LocalEnumCatalog] 下发给渲染层,
|
||||
* 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。
|
||||
*/
|
||||
@Composable
|
||||
fun CyreneRoot(container: AppContainer) {
|
||||
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
|
||||
val enumCatalogViewModel: EnumCatalogViewModel = viewModel(
|
||||
factory = viewModelFactory { initializer { EnumCatalogViewModel(container) } },
|
||||
)
|
||||
val catalog by enumCatalogViewModel.catalog.collectAsState()
|
||||
val darkTheme = when (prefs.themeMode) {
|
||||
ThemeMode.System -> isSystemInDarkTheme()
|
||||
ThemeMode.Light -> false
|
||||
ThemeMode.Dark -> true
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = view.context.findActivity()?.window ?: return@SideEffect
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalAppPrefs provides prefs,
|
||||
LocalEnumCatalog provides catalog,
|
||||
) {
|
||||
CyreneTheme(mode = prefs.themeMode.toColorSchemeMode()) {
|
||||
CyreneApp(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ThemeMode.toColorSchemeMode(): ColorSchemeMode = when (this) {
|
||||
ThemeMode.System -> ColorSchemeMode.System
|
||||
ThemeMode.Light -> ColorSchemeMode.Light
|
||||
ThemeMode.Dark -> ColorSchemeMode.Dark
|
||||
}
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.domain.enums.EnumCatalog
|
||||
|
||||
/**
|
||||
* 枚举权威表(api-client §1.1)的应用级持有者:先用本地快照渲染,再拉一次权威表。
|
||||
*
|
||||
* - 登录 / 静默重登后拉取;**拉取失败保持现状**(快照或原样 id),绝不因此清空角标;
|
||||
* - 快照按 `baseUrl + username` 指纹认领:换服务器/账号即作废;
|
||||
* - 登出时落盘快照与内存标签一并清掉(此时界面只剩登录页,不会闪空角标)。
|
||||
*/
|
||||
class EnumCatalogViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
private val _catalog = MutableStateFlow(EnumCatalog.Empty)
|
||||
val catalog: StateFlow<EnumCatalog> = _catalog.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
container.authManager.state.collect { state ->
|
||||
if (state == SessionState.LoggedOut) {
|
||||
container.enumPreferences.clear()
|
||||
_catalog.value = EnumCatalog.Empty
|
||||
return@collect
|
||||
}
|
||||
val fingerprint = fingerprint()
|
||||
// 先用快照渲染(没有就原样显示 id),拉取不阻塞首帧
|
||||
val snapshot = container.enumPreferences.data.first()
|
||||
_catalog.value = EnumCatalog.of(snapshot.tableFor(fingerprint))
|
||||
if (state is SessionState.LoggedIn) refresh(fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh(fingerprint: String) {
|
||||
viewModelScope.launch {
|
||||
container.metaRepository.enums().onSuccess { table ->
|
||||
container.enumPreferences.save(table, fingerprint)
|
||||
_catalog.value = EnumCatalog.of(table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fingerprint(): String =
|
||||
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
// 冷启动的两步:先清掉上一进程遗留的分享缓存(它已经没有主了),再接这次带来的分享。
|
||||
// 顺序不能反——反过来会把刚拷进来的图一起删掉。
|
||||
ShareIntake.reset(this)
|
||||
// 只在**真正的冷启动**接 intent:Activity 因旋转 / 深色切换而重建时,系统会把原来那个
|
||||
// intent 再送一遍,照单全收就会把同一条分享发第二次。重建带 savedInstanceState,
|
||||
// 冷启动不带——这就是判据。
|
||||
if (savedInstanceState == null) {
|
||||
intent?.let { ShareIntake.accept(this, it) }
|
||||
}
|
||||
val container = (application as CyreneApplication).container
|
||||
setContent {
|
||||
CyreneRoot(container)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 热启动(`launchMode="singleTop"`):App 已经开着时系统不再新建实例,只回调这里。
|
||||
* 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有。
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// 让 getIntent() 与新到的 intent 一致(系统不会自己更新它),否则后续读到的还是旧的那个。
|
||||
setIntent(intent)
|
||||
ShareIntake.accept(this, intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 「分享到 App」的接收入口(`ACTION_SEND` / `ACTION_SEND_MULTIPLE`)。
|
||||
*
|
||||
* ## 它为什么是一个进程级单例
|
||||
* 分享进来时可能还没登录、ChatScreen 甚至还没被组合出来,而登录之后消费它的又是**另一个**
|
||||
* Compose 实例。内容因此必须待在一个不属于任何 UI / ViewModel 的地方——见
|
||||
* [PendingShareHolder]。不塞进 `AppContainer` 是因为那个文件同时被别的并行改动占用,
|
||||
* 这里没有任何依赖需要它:本对象只需要一个 `Context`(读 URI、写 cache)。
|
||||
*
|
||||
* ## 图片为什么在收到的那一刻就落盘
|
||||
* `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页)
|
||||
* 之后再去读,拿到的多半是 `SecurityException` / 打不开。所以字节在这里立刻拷进 `cacheDir`,
|
||||
* 之后一律用本端自己的 `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 都会报图片那一类,风险面很小。
|
||||
*
|
||||
* ## 清理
|
||||
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
|
||||
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
|
||||
* 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。
|
||||
* 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,另加 [stageTextAsDocument]
|
||||
* 为超长正文落下的那一份 `.txt`),另有系统的 cache 回收兜底。
|
||||
*/
|
||||
object ShareIntake {
|
||||
|
||||
private const val DIR_NAME = "share-intake"
|
||||
|
||||
private val holder = PendingShareHolder<SharedContent>()
|
||||
|
||||
/** 待落地的分享内容;消费侧(ChatScreen)订阅它。 */
|
||||
val pending: StateFlow<List<SharedContent>> = holder.pending
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** 进程启动时调用一次:把上一进程遗留的分享缓存清干净。 */
|
||||
fun reset(context: Context) {
|
||||
if (cleanedThisProcess) return
|
||||
cleanedThisProcess = true
|
||||
runCatching { shareDir(context).deleteRecursively() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 「本进程已经清过一次」的标记。
|
||||
*
|
||||
* Activity 会因为旋转 / 分屏 / 深色切换而**重建**,重建就会再走一次 `onCreate`——
|
||||
* 那时清缓存会把用户刚分享进来、还摆在输入框里的那张图删掉(预览变空图、发送也没有字节)。
|
||||
* 缓存副本只对「产生它的进程」有意义,所以判据是**进程**而不是 Activity 实例。
|
||||
*/
|
||||
private var cleanedThisProcess = false
|
||||
|
||||
/**
|
||||
* 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。
|
||||
*
|
||||
* 提取是纯函数([ShareRules.extract]),拷字节是重活,所以在 IO 线程做:`content://` 的读
|
||||
* 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。
|
||||
*/
|
||||
fun accept(context: Context, intent: Intent) {
|
||||
val appContext = context.applicationContext
|
||||
// EXTRA_STREAM 一次读原始值、按**形态**分派(规范形态 + 字符串兼容形态),见 [shareStreamExtrasOf]:
|
||||
// 挨个 getParcelableExtra / getParcelableArrayListExtra 去问的话,遇到字符串形态只会拿到两行
|
||||
// `ClassCastException` 加一个 `null`——那正是「分享进来什么都没发生」的来处。
|
||||
val rawStream = intent.streamExtraValue()
|
||||
val streamExtras = shareStreamExtrasOf(rawStream)
|
||||
val extracted = ShareRules.extract(
|
||||
action = intent.action,
|
||||
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
|
||||
// 单值 / 列表两个字段都喂进去(本端不再按 action 猜该读哪个,见 [ShareRules.extract])。
|
||||
stream = streamExtras.uriForms.firstOrNull(),
|
||||
streams = streamExtras.uriForms,
|
||||
streamStrings = streamExtras.stringForms,
|
||||
// 「里面躺着值」这件事只有这里知道(纯函数看不到原始值):形态认不出的那种也算带着内容,
|
||||
// 否则「分享点下去什么都没发生」会从那条缝里回来。
|
||||
streamPresent = rawStream != null,
|
||||
clipUris = intent.clipUris(),
|
||||
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
|
||||
mimeOf = { uri ->
|
||||
runCatching { context.contentResolver.getType(Uri.parse(uri)) }.getOrNull()
|
||||
},
|
||||
)
|
||||
// 没什么值得往下走的就到此为止(判据是 [SharedContent.worthDelivering]:有可落地的内容,
|
||||
// 或者有丢件 / 读不进来要对用户说清的事)。整条分享里什么都没有时静默是对的;而
|
||||
// 「带着内容迹象却一条都没读进来」必须继续往下走——那条告知在下游 `shareNoticeFor` 里等它。
|
||||
if (!extracted.worthDelivering) return
|
||||
|
||||
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。
|
||||
*
|
||||
* 出队是**唯一的认领方式**:消费它的地方会被重建(重组 / 切会话 / Activity 重建),
|
||||
* 所以「已经发过了」这件事不能记在消费方身上(见 [PendingShareHolder])。
|
||||
*/
|
||||
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`。
|
||||
*
|
||||
* 必须是 `file://` 而不是裸路径:`ContentResolver.openInputStream` 认 `file` scheme,
|
||||
* 但裸路径没有 scheme,会走到「找 content provider」那条路并抛 `FileNotFoundException`。
|
||||
*/
|
||||
private fun copyToCache(context: Context, dir: File, uri: String, index: Int): String? {
|
||||
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return null
|
||||
if (parsed.scheme.isNullOrBlank()) return null
|
||||
|
||||
val target = File(
|
||||
dir,
|
||||
ShareRules.cacheFileName(
|
||||
token = UUID.randomUUID().toString(),
|
||||
index = index,
|
||||
mime = runCatching { context.contentResolver.getType(parsed) }.getOrNull(),
|
||||
),
|
||||
)
|
||||
return try {
|
||||
val source = context.contentResolver.openInputStream(parsed) ?: return null
|
||||
source.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
|
||||
if (target.length() > 0L) {
|
||||
Uri.fromFile(target).toString()
|
||||
} else {
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 半截文件不能留下:下一次发送会把它当一张真图去解码,报出的错会指向错的地方。
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXTRA_STREAM` 的原始值(**故意读原始值**,理由见 [ShareStreamExtras])。
|
||||
*
|
||||
* `Bundle.get` 在 API 33 起被打上废弃:它绕过类型安全。这里要的正是「绕过类型安全」——要判的
|
||||
* 就是「这个值到底是什么形态」。逐类型的 `getXxxExtra` 遇到别的形态只会往 logcat 打一句
|
||||
* `ClassCastException` 再把值扔掉,那正是本次要修的那个静默。
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun Intent.streamExtraValue(): Any? = extras?.get(Intent.EXTRA_STREAM)
|
||||
|
||||
/**
|
||||
* `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).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)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiErrorEnvelope
|
||||
import top.yeij.cyrene.data.api.CyreneApi
|
||||
import top.yeij.cyrene.data.api.dto.LoginRequest
|
||||
import top.yeij.cyrene.data.api.dto.LogoutRequest
|
||||
import top.yeij.cyrene.data.api.dto.RefreshRequest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
sealed interface SessionState {
|
||||
data object Unknown : SessionState
|
||||
data object LoggedOut : SessionState
|
||||
data class LoggedIn(val baseUrl: String, val role: String) : SessionState
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话生命周期:登录、静默恢复、续期、登出。
|
||||
*
|
||||
* B2 起密码**不再落盘**:长期凭据是 refresh token,访问令牌过期时用它续期,
|
||||
* 续期由 [TokenRefresher] 单飞协调(并发 401 只会真的刷一次——服务端轮换无宽限期)。
|
||||
*
|
||||
* @param apiFactory 认证接口的构造方式。默认走真实 Retrofit;单测注入假实现即可覆盖
|
||||
* 登录 / 迁移 / 登出路径,既不需要网络也不需要新增依赖。
|
||||
*/
|
||||
class AuthManager(
|
||||
private val store: SessionStorage,
|
||||
private val apiFactory: (String) -> CyreneApi = { ApiClientFactory.create(baseUrl = it) },
|
||||
) {
|
||||
|
||||
private val _state = MutableStateFlow<SessionState>(SessionState.Unknown)
|
||||
val state: StateFlow<SessionState> = _state.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
var currentBaseUrl: String = ""
|
||||
private set
|
||||
|
||||
private val refresher = TokenRefresher(store, ::refreshCall)
|
||||
|
||||
/** 不带令牌的认证客户端(登录 / 刷新 / 登出),按 baseUrl 缓存。可被 OkHttp 线程读到。 */
|
||||
@Volatile
|
||||
private var authApi: CyreneApi? = null
|
||||
|
||||
@Volatile
|
||||
private var authApiBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 401 处置:能续期就用新令牌重试一次,明确被拒才判终态。
|
||||
* 暂时性失败(网络 / 5xx)**绝不清会话**——见 B2 §6。
|
||||
*/
|
||||
val unauthorizedPolicy: UnauthorizedPolicy = object : UnauthorizedPolicy {
|
||||
override fun renew(usedToken: String): AuthRecovery = refresher.renew(usedToken)
|
||||
|
||||
override fun onSessionEnded() = onSessionExpired()
|
||||
}
|
||||
|
||||
/** tokenProvider 与 WS 握手用:临近过期时阻塞地先换令牌。 */
|
||||
fun currentToken(): String? = refresher.ensureToken()
|
||||
|
||||
/**
|
||||
* WS 握手 401 的一次性恢复:换一次令牌让上层重连。
|
||||
* 与 401 拦截器共用同一条单飞路径,因此 REST 与 WS 同时失效也只会打一次刷新。
|
||||
*/
|
||||
fun recoverSocketAuth(): AuthRecovery = refresher.renew(store.load().token)
|
||||
|
||||
/**
|
||||
* 启动时恢复会话(D12 + B2 迁移)。顺序即优先级:
|
||||
*
|
||||
* 1. **迁移**:没有 refresh token 却还留着旧版的明文密码 → 用旧凭据静默登录一次换 refresh token,
|
||||
* 成功后立刻把它抹掉;失败(例如离线)保留原值、**不登出**,下次启动再试。
|
||||
* 2. 已经有 refresh token → 任何残留的明文密码立刻抹掉。
|
||||
* 3. 访问令牌过期 → 用 refresh token 续期。密码没了以后,这是唯一的静默恢复途径;
|
||||
* 没有了它,隔一天再打开就要重新输密码(比旧版还差)。
|
||||
* 4. 仍然拿不到有效令牌 → 回登录页(username 仍预填)。
|
||||
*/
|
||||
suspend fun bootstrap() = withContext(Dispatchers.IO) {
|
||||
var session = store.load()
|
||||
currentBaseUrl = session.baseUrl
|
||||
|
||||
val legacyUser = session.username?.takeIf(String::isNotBlank)
|
||||
val legacyPassword = session.password?.takeIf(String::isNotBlank)
|
||||
if (session.refreshToken.isNullOrBlank() && legacyUser != null && legacyPassword != null) {
|
||||
doLogin(session.baseUrl, legacyUser, legacyPassword)
|
||||
session = store.load()
|
||||
} else if (!session.password.isNullOrBlank()) {
|
||||
store.purgePassword()
|
||||
session = store.load()
|
||||
}
|
||||
|
||||
if (!session.hasValidToken() && !session.refreshToken.isNullOrBlank()) {
|
||||
refresher.renew(session.token)
|
||||
session = store.load()
|
||||
}
|
||||
|
||||
_state.value = if (session.hasValidToken()) {
|
||||
SessionState.LoggedIn(session.baseUrl, session.role.orEmpty())
|
||||
} else {
|
||||
SessionState.LoggedOut
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(rawBaseUrl: String, username: String, password: String): CyreneError? =
|
||||
withContext(Dispatchers.IO) {
|
||||
doLogin(ApiClientFactory.normalizeBaseUrl(rawBaseUrl), username, password)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动登出:先尽力吊销服务端的 refresh 授权(否则本地「登出」之后那条授权还能活 30 天),
|
||||
* 再抹掉本地一切。吊销失败、超时、离线都不阻断本地清理,所以离线登出依然干净。
|
||||
*/
|
||||
suspend fun logout() = withContext(Dispatchers.IO) {
|
||||
val refreshToken = store.load().refreshToken?.takeIf(String::isNotBlank)
|
||||
if (refreshToken != null) {
|
||||
// 吊销请求不能拖住登出:给一个短上限,超时就当离线处理。
|
||||
withTimeoutOrNull(REVOKE_TIMEOUT_MS) {
|
||||
try {
|
||||
authApi(sessionBaseUrl()).logout(LogoutRequest(refreshToken))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// 尽力而为:离线 / 服务端报错也必须把本地清干净。
|
||||
}
|
||||
}
|
||||
}
|
||||
store.clearAll()
|
||||
authApi = null
|
||||
authApiBaseUrl = null
|
||||
_state.value = SessionState.LoggedOut
|
||||
}
|
||||
|
||||
/** 会话终结(refresh 不可能或明确被拒):清令牌回登录页;username 保留以便预填。 */
|
||||
fun onSessionExpired() {
|
||||
store.clearSession()
|
||||
_state.value = SessionState.LoggedOut
|
||||
}
|
||||
|
||||
private suspend fun doLogin(url: String, username: String, password: String): CyreneError? {
|
||||
return try {
|
||||
val response = authApi(url).login(LoginRequest(username, password))
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null -> {
|
||||
store.saveLogin(url, data.token, data.expiresAt, data.role, username, data.refreshToken)
|
||||
// B2 红线:不再写明文密码。拿到长期凭据后,把历史遗留的那一份也一并抹掉。
|
||||
if (!data.refreshToken.isNullOrBlank()) store.purgePassword()
|
||||
currentBaseUrl = url
|
||||
_state.value = SessionState.LoggedIn(url, data.role)
|
||||
null
|
||||
}
|
||||
|
||||
response.isSuccessful -> CyreneError.Local(CyreneError.LocalKind.Parse)
|
||||
|
||||
else -> {
|
||||
val parsed = parseError(response.errorBody()?.string())
|
||||
if (response.code() == HTTP_UNAUTHORIZED) {
|
||||
CyreneError.Unauthorized(parsed?.code.orEmpty(), parsed?.message.orEmpty())
|
||||
} else {
|
||||
CyreneError.Server(
|
||||
code = parsed?.code.orEmpty(),
|
||||
message = parsed?.message.orEmpty(),
|
||||
httpStatus = response.code(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
mapException(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [TokenRefresher] 注入的阻塞刷新调用。
|
||||
*
|
||||
* 它跑在 OkHttp 的调用线程上(拦截器 / tokenProvider),所以这里用 `runBlocking` 就地驱动
|
||||
* Retrofit 的挂起接口——那正是此刻需要的语义:同步等结果。
|
||||
*
|
||||
* 客户端**不带令牌**,镜像 [doLogin]:既不会让过期令牌搭车,也不会把刷新的 401 送回
|
||||
* 本类的过期处理路径(那两个客户端用的是 [NoUnauthorizedPolicy])。
|
||||
*/
|
||||
private fun refreshCall(refreshToken: String): RefreshCall {
|
||||
val baseUrl = sessionBaseUrl()
|
||||
if (baseUrl.isBlank()) return RefreshCall.Failed
|
||||
return try {
|
||||
val response = runBlocking { authApi(baseUrl).refresh(RefreshRequest(refreshToken)) }
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null ->
|
||||
RefreshCall.Ok(data.token, data.expiresAt, data.refreshToken)
|
||||
|
||||
// 200 但解不动:不拿会话去赌,当暂时性失败。
|
||||
response.isSuccessful -> RefreshCall.Failed
|
||||
|
||||
// 该接口唯一的 401 就是 invalid_grant(未知 / 已吊销 / 已过期 / 已被用掉)。
|
||||
response.code() == HTTP_UNAUTHORIZED -> RefreshCall.Rejected
|
||||
|
||||
else -> RefreshCall.Failed
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
RefreshCall.Failed
|
||||
}
|
||||
}
|
||||
|
||||
private fun authApi(baseUrl: String): CyreneApi {
|
||||
val cached = authApi
|
||||
if (cached != null && authApiBaseUrl == baseUrl) return cached
|
||||
val created = apiFactory(baseUrl)
|
||||
authApi = created
|
||||
authApiBaseUrl = baseUrl
|
||||
return created
|
||||
}
|
||||
|
||||
private fun sessionBaseUrl(): String = currentBaseUrl.ifBlank { store.load().baseUrl }
|
||||
|
||||
private fun StoredSession.hasValidToken(): Boolean =
|
||||
!token.isNullOrBlank() && expiresAt * 1000 > System.currentTimeMillis()
|
||||
|
||||
private fun parseError(body: String?) = body
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
runCatching {
|
||||
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun mapException(e: Throwable): CyreneError = when (e) {
|
||||
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
|
||||
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
|
||||
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
|
||||
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
|
||||
is java.net.UnknownServiceException -> CyreneError.Local(CyreneError.LocalKind.CleartextBlocked)
|
||||
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
|
||||
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HTTP_UNAUTHORIZED = 401
|
||||
|
||||
/** 吊销授权的最长等待:到点就当离线,本地照样清干净。 */
|
||||
const val REVOKE_TIMEOUT_MS = 3_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
data class StoredSession(
|
||||
val baseUrl: String,
|
||||
val token: String?,
|
||||
val expiresAt: Long,
|
||||
val role: String?,
|
||||
val username: String?,
|
||||
/** 长期凭据(B2);B2 之前登录的安装没有这一项,因此可能为 null。 */
|
||||
val refreshToken: String?,
|
||||
/**
|
||||
* **遗留字段**:B2 之前的版本把登录密码落在这里。
|
||||
* 只读用于一次性迁移,绝不再写入;见 [SessionStorage.purgePassword]。
|
||||
*/
|
||||
val password: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* 会话存储契约。[SessionStore] 是唯一的生产实现(EncryptedSharedPreferences);
|
||||
* 单元测试用内存假实现顶替,避免把 Android 的加密存储拖进 JVM 测试。
|
||||
*/
|
||||
interface SessionStorage {
|
||||
|
||||
fun load(): StoredSession
|
||||
|
||||
/** 登录成功:整份会话落盘。**不写密码**(B2 红线)。 */
|
||||
fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* 只替换令牌三元组,保留 baseUrl / username / role。
|
||||
* [refreshToken] 为 null 表示沿用原值——`AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发新的,
|
||||
* 此时旧凭据仍然有效,绝不能丢掉。
|
||||
*/
|
||||
fun updateTokens(token: String, expiresAt: Long, refreshToken: String?)
|
||||
|
||||
/**
|
||||
* 确定性抹掉历史遗留的明文密码。
|
||||
* 用 `commit()` 而不是 `apply()`:这是红线清除,必须在下一次读取之前就落到磁盘上。
|
||||
*/
|
||||
fun purgePassword()
|
||||
|
||||
/** 会话失效:清 token 与 refresh token,保留 username 以便登录页预填。 */
|
||||
fun clearSession()
|
||||
|
||||
/** 主动登出:整份清空。 */
|
||||
fun clearAll()
|
||||
}
|
||||
|
||||
/** JWT 与登录凭据的加密存储(红线:不落明文密码、不打日志)。 */
|
||||
class SessionStore(context: Context) : SessionStorage {
|
||||
|
||||
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
FILE_NAME,
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
override fun load(): StoredSession = StoredSession(
|
||||
baseUrl = prefs.getString(KEY_BASE_URL, "").orEmpty(),
|
||||
token = prefs.getString(KEY_TOKEN, null),
|
||||
expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L),
|
||||
role = prefs.getString(KEY_ROLE, null),
|
||||
username = prefs.getString(KEY_USERNAME, null),
|
||||
refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null),
|
||||
// 只读:迁移路径靠它判断「这是升级前的安装」,不再写入。
|
||||
password = prefs.getString(KEY_PASSWORD, null),
|
||||
)
|
||||
|
||||
override fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
) {
|
||||
val editor = prefs.edit()
|
||||
.putString(KEY_BASE_URL, baseUrl)
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putLong(KEY_EXPIRES_AT, expiresAt)
|
||||
.putString(KEY_ROLE, role)
|
||||
.putString(KEY_USERNAME, username)
|
||||
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
|
||||
val editor = prefs.edit()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putLong(KEY_EXPIRES_AT, expiresAt)
|
||||
// 轮换关闭时服务端不下发新凭据,旧的那一份继续有效,必须保留。
|
||||
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
override fun purgePassword() {
|
||||
prefs.edit().remove(KEY_PASSWORD).commit()
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
prefs.edit()
|
||||
.remove(KEY_TOKEN)
|
||||
.remove(KEY_EXPIRES_AT)
|
||||
.remove(KEY_ROLE)
|
||||
.remove(KEY_REFRESH_TOKEN)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE_NAME = "cyrene_session"
|
||||
const val KEY_BASE_URL = "base_url"
|
||||
const val KEY_TOKEN = "token"
|
||||
const val KEY_EXPIRES_AT = "expires_at"
|
||||
const val KEY_ROLE = "role"
|
||||
const val KEY_USERNAME = "username"
|
||||
const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
const val KEY_PASSWORD = "password"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
/** 一次令牌续期的结果。 */
|
||||
sealed interface AuthRecovery {
|
||||
|
||||
/** 已拿到可用的访问令牌:可能是刚签发的,也可能是别的线程刚换好的。 */
|
||||
data class Renewed(val token: String) : AuthRecovery
|
||||
|
||||
/**
|
||||
* 无法续期:本地没有 refresh token,或被服务端以 `invalid_grant` 拒绝
|
||||
* (未知 / 已吊销 / 已过期 / 已被用掉,服务端把这四种情况折叠成一个码)。
|
||||
* 这是终态——必须重新登录。
|
||||
*/
|
||||
data object Unrecoverable : AuthRecovery
|
||||
|
||||
/** 暂时性失败:网络不通、5xx、响应解不动。会话可能仍然有效,稍后再试。 */
|
||||
data object Transient : AuthRecovery
|
||||
}
|
||||
|
||||
/**
|
||||
* [TokenRefresher] 注入的刷新调用结果。
|
||||
* 把 HTTP 细节挡在协调逻辑之外,单飞与代际守卫因此可以纯 JVM 单测。
|
||||
*/
|
||||
sealed interface RefreshCall {
|
||||
|
||||
/** 200:新的访问令牌;[refreshToken] 为 null 表示服务端关闭了轮换、旧凭据继续有效。 */
|
||||
data class Ok(val token: String, val expiresAt: Long, val refreshToken: String?) : RefreshCall
|
||||
|
||||
/** `401 invalid_grant`。 */
|
||||
data object Rejected : RefreshCall
|
||||
|
||||
/** 网络 / 5xx / 解析失败。 */
|
||||
data object Failed : RefreshCall
|
||||
}
|
||||
|
||||
/**
|
||||
* 单飞(single-flight)刷新协调器。
|
||||
*
|
||||
* ## 为什么必须单飞
|
||||
* 服务端 `RefreshStore.Rotate` 是**原子且无宽限期**的:同一个 refresh token 被第二次使用会立刻
|
||||
* 拿到 `invalid_grant`(防重放)。而本端并发 401 是常态——管理概览页两个请求并发、聊天页 REST 与
|
||||
* WS 同时起步。所以同一时刻只允许一个线程真正发刷新请求,其余线程在锁上等待后复用结果。
|
||||
*
|
||||
* ## 代际守卫
|
||||
* 拿到锁之后**重新读一次存储**:如果已存的访问令牌既不是自己手里那份([renew] 的 `staleToken`)、
|
||||
* 又还没到过期点,说明等在锁上的这段时间里别的线程已经换好了令牌——直接复用,绝不刷第二次。
|
||||
*
|
||||
* ## 线程模型
|
||||
* 全部逻辑跑在**调用方线程**上(OkHttp 的调用线程 / WS 重连协程),用 JVM 监视器锁串行化。
|
||||
* 因此这里没有、也不能用协程 `Mutex`——调用点根本没有 suspend 上下文。
|
||||
* 真实的刷新请求由调用方用**不带令牌**的客户端发出(见 `AuthManager.refreshCall`),
|
||||
* 不会递归回本类。
|
||||
*
|
||||
* @param store 会话读写;实现必须让写入立刻对下一次 [SessionStorage.load] 可见。
|
||||
* @param call 真正发刷新请求的阻塞调用。
|
||||
* @param now 当前毫秒时间戳,可注入以便单测。
|
||||
*/
|
||||
class TokenRefresher(
|
||||
private val store: SessionStorage,
|
||||
private val call: (refreshToken: String) -> RefreshCall,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
/**
|
||||
* 已经确定无效的那个 refresh token。`invalid_grant` 对该值而言是**永久**结论,
|
||||
* 记下来可以让同一次失效里的其它并发 401 直接得到终态,不必各打一次注定失败的请求。
|
||||
* 值本身随机且每次轮换都变,因此不需要额外失效逻辑。
|
||||
*/
|
||||
private var rejectedRefreshToken: String? = null
|
||||
|
||||
/** 距过期不足 [SKEW_MS] 就算「该提前换令牌了」。 */
|
||||
fun isNearExpiry(session: StoredSession): Boolean =
|
||||
session.expiresAt * 1000 - now() <= SKEW_MS
|
||||
|
||||
/**
|
||||
* 阻塞地给出一个当下可用的访问令牌;临近过期时先走一次单飞刷新。
|
||||
* 供 OkHttp 的 tokenProvider 与 WS 握手使用(每次重连都会重新读)。
|
||||
*
|
||||
* 刷新失败时**仍然返回手里那份令牌**——它可能还没真的过期,401 交给兜底路径处理;
|
||||
* 在这里返回 null 会让整条请求链断掉,比让服务端来裁决更糟。
|
||||
*/
|
||||
fun ensureToken(): String? {
|
||||
val session = store.load()
|
||||
val token = session.token?.takeIf(String::isNotBlank) ?: return null
|
||||
if (!isNearExpiry(session)) return token
|
||||
return when (val recovery = renew(token)) {
|
||||
is AuthRecovery.Renewed -> recovery.token
|
||||
AuthRecovery.Unrecoverable, AuthRecovery.Transient -> token
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单飞刷新:同一时刻只有一个线程真正发请求,其余线程在锁上等待并复用结果。
|
||||
*
|
||||
* @param staleToken 触发续期的那次请求实际携带的令牌;null 表示只知道「手上没有可用的令牌」。
|
||||
*/
|
||||
fun renew(staleToken: String?): AuthRecovery = synchronized(lock) {
|
||||
val session = store.load()
|
||||
|
||||
// 代际守卫:等在锁上的这段时间里,别的线程已经换好了令牌 → 复用,绝不用同一个 refresh token 打第二次。
|
||||
val current = session.token
|
||||
if (!current.isNullOrBlank() && current != staleToken && !isNearExpiry(session)) {
|
||||
return@synchronized AuthRecovery.Renewed(current)
|
||||
}
|
||||
|
||||
val refreshToken = session.refreshToken?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized AuthRecovery.Unrecoverable
|
||||
if (refreshToken == rejectedRefreshToken) return@synchronized AuthRecovery.Unrecoverable
|
||||
|
||||
when (val result = call(refreshToken)) {
|
||||
is RefreshCall.Ok -> {
|
||||
rejectedRefreshToken = null
|
||||
store.updateTokens(result.token, result.expiresAt, result.refreshToken)
|
||||
AuthRecovery.Renewed(result.token)
|
||||
}
|
||||
|
||||
RefreshCall.Rejected -> {
|
||||
rejectedRefreshToken = refreshToken
|
||||
AuthRecovery.Unrecoverable
|
||||
}
|
||||
|
||||
RefreshCall.Failed -> AuthRecovery.Transient
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 提前量:24h 的访问令牌留 60s 余量,避免踩着过期边界发请求。 */
|
||||
const val SKEW_MS = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
/**
|
||||
* 「带了令牌仍然 401」的处置策略。
|
||||
*
|
||||
* 由 `ApiClientFactory` 的拦截器在 **OkHttp 的调用线程上同步调用**,所以实现必须是
|
||||
* 阻塞且线程安全的——那里没有 suspend 上下文,协程 `Mutex` 帮不上忙。
|
||||
*/
|
||||
interface UnauthorizedPolicy {
|
||||
|
||||
/**
|
||||
* 尝试把被服务端拒绝的令牌换成可用的新令牌。
|
||||
*
|
||||
* @param usedToken 触发这次 401 的请求实际携带的访问令牌。
|
||||
* @return [AuthRecovery.Renewed] 时调用方会用新令牌重试一次;另外两种都不重试。
|
||||
*/
|
||||
fun renew(usedToken: String): AuthRecovery
|
||||
|
||||
/** 终态:续期不可能(没有 refresh token)或明确被拒(`invalid_grant`)——必须重新登录。 */
|
||||
fun onSessionEnded()
|
||||
}
|
||||
|
||||
/** 不带令牌的客户端(登录 / 刷新 / 登出)用:永不续期,也永不判终态。 */
|
||||
object NoUnauthorizedPolicy : UnauthorizedPolicy {
|
||||
|
||||
override fun renew(usedToken: String): AuthRecovery = AuthRecovery.Unrecoverable
|
||||
|
||||
override fun onSessionEnded() = Unit
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package top.yeij.cyrene.core.error
|
||||
|
||||
/**
|
||||
* 统一错误模型:区分「服务端有响应」与「本地/网络层失败」。
|
||||
* 约定(S1.1):已建立连接时用服务端 `error.message`,本地错误由客户端负责文案。
|
||||
*/
|
||||
sealed interface CyreneError {
|
||||
val userMessage: String
|
||||
|
||||
data class Server(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val httpStatus: Int,
|
||||
) : CyreneError {
|
||||
override val userMessage: String
|
||||
get() = message.ifBlank { "服务端错误(HTTP $httpStatus)" }
|
||||
}
|
||||
|
||||
data class Unauthorized(
|
||||
val code: String,
|
||||
val message: String,
|
||||
) : CyreneError {
|
||||
override val userMessage: String
|
||||
get() = message.ifBlank { "用户名或密码不正确" }
|
||||
}
|
||||
|
||||
data class Local(val kind: LocalKind) : CyreneError {
|
||||
override val userMessage: String
|
||||
get() = when (kind) {
|
||||
LocalKind.InvalidUrl -> "服务器地址格式不对,示例:http://192.168.1.10:8080"
|
||||
LocalKind.NoNetwork -> "网络不可用,请检查连接"
|
||||
LocalKind.Dns -> "找不到服务器,请检查服务器地址"
|
||||
LocalKind.Tls -> "证书校验失败(自签证书暂不支持)"
|
||||
LocalKind.Timeout -> "连接超时,服务器没有响应"
|
||||
LocalKind.ConnectFailed -> "无法连接到服务器"
|
||||
LocalKind.CleartextBlocked -> "系统禁止明文 HTTP 访问"
|
||||
LocalKind.Parse -> "服务器返回的数据无法识别"
|
||||
LocalKind.Unknown -> "发生未知错误"
|
||||
}
|
||||
}
|
||||
|
||||
enum class LocalKind {
|
||||
InvalidUrl,
|
||||
NoNetwork,
|
||||
Dns,
|
||||
Tls,
|
||||
Timeout,
|
||||
ConnectFailed,
|
||||
CleartextBlocked,
|
||||
Parse,
|
||||
Unknown,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_app")
|
||||
|
||||
enum class ThemeMode { System, Light, Dark }
|
||||
|
||||
/**
|
||||
* 她忙时发消息的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`;忙时并行规范 §2.2 的
|
||||
* `chat.parallel`)。
|
||||
*
|
||||
* 类型名与落盘键(`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?, val wireParallel: Boolean) {
|
||||
|
||||
/**
|
||||
* 追加队列(默认):忙时只把消息排进队列,不打断也不并行。
|
||||
*
|
||||
* 该模式**两个声明都不发**(`interruptScopeForSend` 恒为「不发」、`parallelForSend` 恒为
|
||||
* `false`):默认档的线上请求与本偏好存在之前**逐字节相同**。
|
||||
*/
|
||||
AppendQueue(wireScope = null, wireParallel = false),
|
||||
|
||||
/**
|
||||
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"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
|
||||
}
|
||||
|
||||
/**
|
||||
* App 级偏好(非机密)。
|
||||
*
|
||||
* @param themeMode 主题深浅
|
||||
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
|
||||
* @param thinkingExpanded 思维链卡片默认是否展开
|
||||
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
|
||||
*/
|
||||
data class AppPrefs(
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
val showNarration: Boolean = true,
|
||||
val thinkingExpanded: Boolean = true,
|
||||
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
|
||||
)
|
||||
|
||||
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
|
||||
val LocalAppPrefs = staticCompositionLocalOf { AppPrefs() }
|
||||
|
||||
class AppSettings(context: Context) {
|
||||
|
||||
private val store: DataStore<Preferences> = context.applicationContext.appDataStore
|
||||
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
store.edit { it[KEY_THEME_MODE] = mode.name }
|
||||
}
|
||||
|
||||
suspend fun setShowNarration(enabled: Boolean) {
|
||||
store.edit { it[KEY_SHOW_NARRATION] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setThinkingExpanded(enabled: Boolean) {
|
||||
store.edit { it[KEY_THINKING_EXPANDED] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setInterruptMode(mode: InterruptMode) {
|
||||
store.edit { it[KEY_INTERRUPT_MODE] = mode.name }
|
||||
}
|
||||
|
||||
private fun String?.toThemeMode(): ThemeMode = when (this) {
|
||||
ThemeMode.Light.name -> ThemeMode.Light
|
||||
ThemeMode.Dark.name -> ThemeMode.Dark
|
||||
else -> ThemeMode.System
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.dto.EnumTableDto
|
||||
|
||||
private val Context.enumDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_enums")
|
||||
|
||||
/**
|
||||
* 枚举权威表的本地回退快照(§1.1)。
|
||||
*
|
||||
* @param table 上次成功拉取的权威表;拉取失败时先用它渲染(绝不因此清空角标)
|
||||
* @param fingerprint `baseUrl + username`:换服务器/账号后快照作废
|
||||
*/
|
||||
data class EnumPrefs(
|
||||
val table: EnumTableDto? = null,
|
||||
val fingerprint: String = "",
|
||||
) {
|
||||
|
||||
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的标签拿来用。 */
|
||||
fun tableFor(fingerprint: String): EnumTableDto? = table?.takeIf { this.fingerprint == fingerprint }
|
||||
}
|
||||
|
||||
class EnumPreferences(context: Context) {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val store: DataStore<Preferences> = context.applicationContext.enumDataStore
|
||||
|
||||
val data: Flow<EnumPrefs> = store.data.map { prefs ->
|
||||
EnumPrefs(
|
||||
table = prefs[KEY_TABLE].decodeTable(),
|
||||
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun save(table: EnumTableDto, fingerprint: String) {
|
||||
store.edit { prefs ->
|
||||
prefs[KEY_TABLE] = json.encodeToString(EnumTableDto.serializer(), table)
|
||||
prefs[KEY_FINGERPRINT] = fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
/** 登出 / 切换服务器时清空。 */
|
||||
suspend fun clear() {
|
||||
store.edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun String?.decodeTable(): EnumTableDto? = this
|
||||
?.let { runCatching { json.decodeFromString(EnumTableDto.serializer(), it) }.getOrNull() }
|
||||
|
||||
private companion object {
|
||||
val KEY_TABLE = stringPreferencesKey("enum_table")
|
||||
val KEY_FINGERPRINT = stringPreferencesKey("enum_fingerprint")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
|
||||
private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_sessions")
|
||||
|
||||
/**
|
||||
* 会话列表的本地回退快照与显示态(§3.1)。
|
||||
*
|
||||
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
|
||||
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
|
||||
* @param fingerprint [accountFingerprint]:换服务器/账号后快照作废
|
||||
* @param activeSessionId 上次停留的会话
|
||||
*/
|
||||
data class SessionPrefs(
|
||||
val snapshot: List<SessionSummaryDto> = emptyList(),
|
||||
val snapshotAt: Long = 0L,
|
||||
val fingerprint: String = "",
|
||||
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
|
||||
) {
|
||||
|
||||
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的会话列表拿来用。 */
|
||||
fun snapshotFor(fingerprint: String): List<SessionSummaryDto> =
|
||||
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
|
||||
|
||||
private val store: DataStore<Preferences> = context.applicationContext.sessionDataStore
|
||||
|
||||
val data: Flow<SessionPrefs> = store.data.map { prefs ->
|
||||
SessionPrefs(
|
||||
snapshot = prefs[KEY_SNAPSHOT].decodeSessions(),
|
||||
snapshotAt = prefs[KEY_SNAPSHOT_AT] ?: 0L,
|
||||
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
|
||||
activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID,
|
||||
)
|
||||
}
|
||||
|
||||
/** 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵)。 */
|
||||
suspend fun saveSnapshot(sessions: List<SessionSummaryDto>, fingerprint: String) {
|
||||
store.edit { prefs ->
|
||||
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions)
|
||||
prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis()
|
||||
prefs[KEY_FINGERPRINT] = fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setActive(sessionId: String) {
|
||||
store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId }
|
||||
}
|
||||
|
||||
/** 删除会话:必要时把 active 退回主会话,并同步从快照里剔除。 */
|
||||
suspend fun removeSession(sessionId: String) {
|
||||
store.edit { prefs ->
|
||||
if (prefs[KEY_ACTIVE] == sessionId) {
|
||||
prefs[KEY_ACTIVE] = SessionSummaryDto.MAIN_SESSION_ID
|
||||
}
|
||||
|
||||
val remaining = prefs[KEY_SNAPSHOT].decodeSessions().filterNot { it.id == sessionId }
|
||||
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
/** 登出时清空:快照、指纹、active 一起走,下次登录不会先闪一份旧列表。 */
|
||||
suspend fun clear() {
|
||||
store.edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun String?.decodeSessions(): List<SessionSummaryDto> = this
|
||||
?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() }
|
||||
.orEmpty()
|
||||
|
||||
private companion object {
|
||||
val KEY_SNAPSHOT = stringPreferencesKey("sessions_snapshot")
|
||||
val KEY_SNAPSHOT_AT = longPreferencesKey("sessions_snapshot_at")
|
||||
val KEY_FINGERPRINT = stringPreferencesKey("sessions_fingerprint")
|
||||
val KEY_ACTIVE = stringPreferencesKey("active_session_id")
|
||||
|
||||
val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package top.yeij.cyrene.core.session
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* 本端**已经知道服务端没有这个会话**的那几个会话 id(进程级、只在内存里、只增不减)。
|
||||
*
|
||||
* ## 它回答的是哪一个问题
|
||||
* 「这个 id 还值不值得我发一次请求」。`ChatViewModel` 每个访问过的会话各留一个常驻 VM
|
||||
* (切走 / 删会话都不回收),而它们都订阅同一条共享连接 ⇒ **每次重连各自会去补拉一轮**。会话
|
||||
* 在本端被删掉之后,那个 VM 并不知道(它没有别的渠道知道),于是**每一个**重连都发一次注定
|
||||
* `404 session_not_found` 的补拉,404 被静默吞掉(S7 登记的那一条)。
|
||||
*
|
||||
* 补拉**事前**就能判掉它,靠的就是这里:删除是**本端自己做的**,那一刻就已经知道结果了,
|
||||
* 没有任何理由等下一条请求回来才「发现」。于是这一位把「已知不存在」从「一次注定失败的往返」
|
||||
* 提前成「一次本地查询」——**不是**「重连时少拉一点」的省流量优化,而是不让一个注定失败的请求
|
||||
* 上路(它会占一条连接、写一行日志、并把 `sessionGone` 变成一个只能靠 404 才置得起来的位)。
|
||||
*
|
||||
* ## 为什么是进程级、为什么**不**持久化
|
||||
* - 进程级(挂在 `AppContainer` 上,与那条共享连接同一层):写它的是 `SessionsViewModel`
|
||||
* (本端唯一的删除入口),读它的是**别的** VM —— 两个 VM 之间没有别的共享面。
|
||||
* - **不持久化**:它只是「这一趟进程里已经知道的事」。重启之后本端的会话列表会重新从服务端拉
|
||||
* ([top.yeij.cyrene.core.prefs.SessionPreferences] 那份快照本来就会整体替换),「这个会话还在不在」
|
||||
* 以那一次服务端答复为准;把 id 落盘只会让一份陈旧记忆挡掉重启后的第一次拉取。
|
||||
* - **换账号要清**([clear]):id 是服务端铸的,理论上不会跨账号撞车,但这一位说的是「本端
|
||||
* 已经知道」,而换账号之后本端**什么都不知道** —— 留着上个环境的结论,是那种「差一个分隔符
|
||||
* 就漏过去」的同一类错误(先例:`accountFingerprint` 的注释)。
|
||||
*/
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package top.yeij.cyrene.core.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.ThemeController
|
||||
|
||||
@Composable
|
||||
fun CyreneTheme(
|
||||
mode: ColorSchemeMode = ColorSchemeMode.System,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val controller = remember(mode) { ThemeController(mode) }
|
||||
MiuixTheme(controller = controller, content = content)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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
|
||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* 管理只读监控端点(api-client §2,全部 `GET`,无写操作)。
|
||||
*
|
||||
* RBAC 由服务端兜底(`viewer` 对 ②③ 档一律 403),客户端按 §2.0 权限矩阵**自行降级**,
|
||||
* 见 [top.yeij.cyrene.domain.admin.AdminAccess];这里只负责取数,不做权限判断。
|
||||
*/
|
||||
interface AdminApi {
|
||||
|
||||
/** 网关请求指标;`qps` 为近 1 秒窗口计数。 */
|
||||
@GET("api/v1/admin/observability/metrics")
|
||||
suspend fun metrics(): Response<ApiEnvelope<AdminMetricsDto>>
|
||||
|
||||
/** 最近 trace span,服务端按时间倒序(最新在前);`limit` 缺省 50、上限 500。 */
|
||||
@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>>>
|
||||
|
||||
/** 私人记忆正文;`viewer` 会拿到 403。 */
|
||||
@GET("api/v1/admin/memories")
|
||||
suspend fun memories(): Response<ApiEnvelope<List<MemorySummaryDto>>>
|
||||
|
||||
/** 后台思考记录;`viewer` 会拿到 403。 */
|
||||
@GET("api/v1/admin/backthink")
|
||||
suspend fun backthink(): Response<ApiEnvelope<List<AdminBackthinkItemDto>>>
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.BuildConfig
|
||||
import top.yeij.cyrene.core.auth.AuthRecovery
|
||||
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
|
||||
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
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 {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/** 登录 / 刷新 / 登出接口(不注入 token、不参与续期)。 */
|
||||
fun create(
|
||||
baseUrl: String,
|
||||
tokenProvider: () -> String? = { null },
|
||||
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
|
||||
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, unauthorizedPolicy)
|
||||
|
||||
/**
|
||||
* 通用入口:按需构建任意 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>,
|
||||
tokenProvider: () -> String? = { null },
|
||||
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
|
||||
): T {
|
||||
val client = OkHttpClient.Builder()
|
||||
// 连接这一段刻意短(理由见 [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) {
|
||||
addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
// BASIC 是上限:`/auth/refresh` 与 `/auth/logout` 把 refresh token 放在**请求体**里,
|
||||
// 再高一级就会把凭据打进 logcat。
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
redactHeader("Authorization")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(normalizeBaseUrl(baseUrl))
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(service)
|
||||
}
|
||||
|
||||
/** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */
|
||||
fun normalizeBaseUrl(raw: String): String {
|
||||
var url = raw.trim()
|
||||
if (url.isEmpty()) return url
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "http://$url"
|
||||
}
|
||||
if (!url.endsWith("/")) url += "/"
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入 `X-API-Version` 与 `Authorization`,并兜住「**带了令牌仍然** 401」:
|
||||
* 先向策略要一次新令牌,拿到就用它**重试一次**;明确被拒才判终态。
|
||||
*
|
||||
* 为什么是应用层拦截器而不是 OkHttp `Authenticator`:
|
||||
* `Authenticator` 对**任何** 401 都会被调用(包括不带令牌的登录请求),而且它由
|
||||
* RetryAndFollowUp 在内部循环里驱动,重试次数(上限 20 次)不受本类控制——一个持续 401 的
|
||||
* 令牌会打出一串刷新风暴。应用层拦截器只在带令牌时介入,且重试严格一次
|
||||
* (OkHttp 允许应用层拦截器多次 `proceed()`,`calls != 1` 的检查只对网络拦截器生效)。
|
||||
*/
|
||||
private class AuthInterceptor(
|
||||
private val tokenProvider: () -> String?,
|
||||
private val unauthorizedPolicy: UnauthorizedPolicy,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val token = tokenProvider()
|
||||
var response = chain.proceed(chain.request().withAuth(token))
|
||||
// 只有「带了 token 仍 401」才算会话被拒;登录/刷新本身不带 token,不受影响。
|
||||
if (response.code != HTTP_UNAUTHORIZED || token == null) return response
|
||||
|
||||
when (val recovery = unauthorizedPolicy.renew(token)) {
|
||||
is AuthRecovery.Renewed -> {
|
||||
response.close()
|
||||
response = chain.proceed(chain.request().withAuth(recovery.token))
|
||||
// 换上刚签发的令牌仍然 401:服务端根本不接受这个账号,再刷也没有意义,判终态。
|
||||
if (response.code == HTTP_UNAUTHORIZED) unauthorizedPolicy.onSessionEnded()
|
||||
}
|
||||
|
||||
AuthRecovery.Unrecoverable -> unauthorizedPolicy.onSessionEnded()
|
||||
|
||||
// 网络 / 5xx:会话可能还好着,原样把 401 交回去,绝不清会话。
|
||||
AuthRecovery.Transient -> Unit
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun Request.withAuth(token: String?): Request = newBuilder()
|
||||
.header("X-API-Version", "1")
|
||||
.apply { if (token != null) header("Authorization", "Bearer $token") }
|
||||
.build()
|
||||
}
|
||||
|
||||
private const val HTTP_UNAUTHORIZED = 401
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** 后端统一信封:成功 `{data}`,失败 `{error:{code,message}}`。 */
|
||||
@Serializable
|
||||
data class ApiEnvelope<T>(
|
||||
val data: T? = null,
|
||||
val error: ApiError? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiErrorEnvelope(
|
||||
val error: ApiError? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiError(
|
||||
val code: String = "",
|
||||
val message: String = "",
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.SerializationException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* 统一错误映射(S1.1):已建连接用服务端 `error`,本地异常按类型分类。
|
||||
* 供所有 REST 仓库复用;不要在各个仓库里各写一份。
|
||||
*/
|
||||
object ApiErrorMapper {
|
||||
|
||||
/** 有 HTTP 响应:401 → [CyreneError.Unauthorized],其余 → [CyreneError.Server]。 */
|
||||
fun fromHttp(httpStatus: Int, errorBody: String?): CyreneError {
|
||||
val error = parseErrorEnvelope(errorBody)
|
||||
val code = error?.code.orEmpty()
|
||||
val message = error?.message.orEmpty()
|
||||
return if (httpStatus == 401) {
|
||||
CyreneError.Unauthorized(code = code, message = message)
|
||||
} else {
|
||||
CyreneError.Server(code = code, message = message, httpStatus = httpStatus)
|
||||
}
|
||||
}
|
||||
|
||||
/** 本地/网络异常 → [CyreneError.Local]。 */
|
||||
fun fromThrowable(throwable: Throwable): CyreneError = when (throwable) {
|
||||
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
|
||||
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
|
||||
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
|
||||
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
|
||||
is SerializationException -> CyreneError.Local(CyreneError.LocalKind.Parse)
|
||||
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
|
||||
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
|
||||
}
|
||||
|
||||
/** 解析 `{"error":{code,message}}`;非信封 / 空白 / 解析失败均返回 null。 */
|
||||
fun parseErrorEnvelope(errorBody: String?): ApiError? = errorBody
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
runCatching {
|
||||
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 [CyreneError] 塞进 Kotlin `Result` 的载体,便于 `Result<T>` 统一传递失败。 */
|
||||
class CyreneException(val error: CyreneError) : Exception(error.userMessage)
|
||||
|
||||
/**
|
||||
* 执行一次信封调用并把结果收敛成 `Result<T>`:
|
||||
* 成功且 `data != null` → success;成功但 `data == null` → Parse;否则走 [ApiErrorMapper]。
|
||||
*/
|
||||
suspend fun <T : Any> apiCall(block: suspend () -> Response<ApiEnvelope<T>>): Result<T> =
|
||||
try {
|
||||
val response = block()
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null -> Result.success(data)
|
||||
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)))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.ChatAckDto
|
||||
import top.yeij.cyrene.data.api.dto.ChatRequest
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* 发送一条消息(`POST /api/v1/chat`,api-client §1 行 3)。权限 `owner|admin`。
|
||||
*
|
||||
* 与会话只读的 [SessionsApi]、管理面 [AdminApi] 分开:这是**写**路径,且它的失败面比只读端点
|
||||
* 复杂得多(`202` / `409` / `500` / `503` 都是编排器的真实回执,不是「请求错了」),
|
||||
* 放在一起会让只读端点的调用方以为错误处理是同一种。
|
||||
*
|
||||
* 返回原始 [Response](而不是直接返回信封):状态码本身是语义的一部分——
|
||||
* `202` 排队 与 `409` 排队满 必须能被调用方分开,折进 `Result` 前就先丢了。
|
||||
*/
|
||||
interface ChatApi {
|
||||
|
||||
@POST("api/v1/chat")
|
||||
suspend fun sendChat(@Body body: ChatRequest): Response<ApiEnvelope<ChatAckDto>>
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.LoginRequest
|
||||
import top.yeij.cyrene.data.api.dto.LoginResponse
|
||||
import top.yeij.cyrene.data.api.dto.LogoutRequest
|
||||
import top.yeij.cyrene.data.api.dto.LogoutResponse
|
||||
import top.yeij.cyrene.data.api.dto.RefreshRequest
|
||||
import top.yeij.cyrene.data.api.dto.RefreshResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface CyreneApi {
|
||||
|
||||
@POST("api/v1/auth/login")
|
||||
suspend fun login(@Body body: LoginRequest): Response<ApiEnvelope<LoginResponse>>
|
||||
|
||||
@POST("api/v1/auth/refresh")
|
||||
suspend fun refresh(@Body body: RefreshRequest): Response<ApiEnvelope<RefreshResponse>>
|
||||
|
||||
@POST("api/v1/auth/logout")
|
||||
suspend fun logout(@Body body: LogoutRequest): Response<ApiEnvelope<LogoutResponse>>
|
||||
}
|
||||
@@ -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,16 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.EnumTableDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
|
||||
/**
|
||||
* 共享枚举权威表端点(api-client §1.1 / api-reference §3.7)。
|
||||
* 任意已认证角色可读(与 `GET /sessions` 同级),无 token 回 `401`;
|
||||
* 纯内存投影,没有其它错误码。按本工程惯例单独成接口。
|
||||
*/
|
||||
interface MetaApi {
|
||||
|
||||
@GET("api/v1/meta/enums")
|
||||
suspend fun enums(): Response<ApiEnvelope<EnumTableDto>>
|
||||
}
|
||||
@@ -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>>>
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* 会话与消息相关端点(api-reference §3.2/§3.4/§3.5/§3.6)。
|
||||
* 与 M1 的 `CyreneApi` 分开,避免互相污染。
|
||||
*/
|
||||
interface SessionsApi {
|
||||
|
||||
@GET("api/v1/sessions")
|
||||
suspend fun listSessions(): Response<ApiEnvelope<List<SessionSummaryDto>>>
|
||||
|
||||
/**
|
||||
* 新建次级会话,**完全不带 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>>
|
||||
|
||||
/**
|
||||
* 重命名 / 置顶(`writeAuth`:owner 或 admin)。主会话与次级会话都能改。
|
||||
*
|
||||
* - 请求体至少要带一个字段;`{}` → 400。字段缺席即保留原值。
|
||||
* - `title` 会被 trim;空/全空白 → 400 `title is required`(措辞误导,实际含义是
|
||||
* **无法把标题清回自动命名**);超过 80 个字符 → 400。
|
||||
* - 两个字段同时给出时**先改名再置顶**:置顶失败时改名已经落库,不能当成「什么都没发生」。
|
||||
* - 响应是局部形状,见 [UpdateSessionResponse]。
|
||||
*/
|
||||
@PATCH("api/v1/sessions/{id}")
|
||||
suspend fun updateSession(
|
||||
@Path("id") sessionId: String,
|
||||
@Body body: UpdateSessionRequest,
|
||||
): Response<ApiEnvelope<UpdateSessionResponse>>
|
||||
|
||||
/**
|
||||
* 消息热分页,服务端按 `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(
|
||||
@Path("id") sessionId: String,
|
||||
@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>>>
|
||||
|
||||
/**
|
||||
* 推进已读游标(D4,契约 v0.37 `api-reference §3.6b`)。
|
||||
*
|
||||
* `before` 缺席 = 「现在」;游标**只前进**,所以重复 / 乱序 / 重试都幂等安全。
|
||||
* 错误面:`before` 非法 → `400`、未知会话(含隔离 `sess-xxx`)→ `404`、
|
||||
* 未注入 HistoryStore → `501`。成功后服务端会广播 `session_read`(§3.9)。
|
||||
*/
|
||||
@POST("api/v1/sessions/{id}/read")
|
||||
suspend fun markRead(
|
||||
@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,33 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.UploadResponse
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
|
||||
/**
|
||||
* 图片上传端点(`POST /api/v1/upload`,api-client §1 行 13)。权限 `owner|admin`。
|
||||
*
|
||||
* **上传本身不是目的**:服务端把 `chat.image` 字符串**原样落库**,于是每一页历史与每一次
|
||||
* WS `history` 回放都要把整段 base64 再传一遍(协同 G3)。上传一次、消息里只留 URL,
|
||||
* 是这条链路上唯一能把「历史重量」永久压下去的手段。
|
||||
*
|
||||
* 与 [ChatApi] 分开:它的请求体是 multipart(不是 JSON)、错误面也完全不同
|
||||
* (`413 payload_too_large` / `415 unsupported_media_type` 是它的日常回执)。
|
||||
*
|
||||
* 返回原始 [Response] 而不是直接折成 `Result`:与其它端点一样由仓库层统一走
|
||||
* [ApiErrorMapper],状态码在这里**不承载额外语义**(`413`/`415` 的机器可读区分在
|
||||
* `error.code` 里,所以仓库能用 [apiCall])。
|
||||
*/
|
||||
interface UploadApi {
|
||||
|
||||
/**
|
||||
* @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`):字段名错了会回
|
||||
* `400 {error:{code:"bad_request",message:"file is required"}}`(已对着在线后端实测)。
|
||||
*/
|
||||
@Multipart
|
||||
@POST("api/v1/upload")
|
||||
suspend fun upload(@Part file: MultipartBody.Part): Response<ApiEnvelope<UploadResponse>>
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/observability/metrics`(api-client §2「可观测性契约」)。
|
||||
* 后端不统计 token / 成本,因此这里也没有对应字段——不伪造指标。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminMetricsDto(
|
||||
val totalRequests: Long = 0,
|
||||
val qps: Double = 0.0,
|
||||
val avgLatencyMs: Double = 0.0,
|
||||
val maxLatencyMs: Long = 0,
|
||||
val perMethod: Map<String, Long> = emptyMap(),
|
||||
val perPath: Map<String, Long> = emptyMap(),
|
||||
val statusCounts: Map<String, Long> = emptyMap(),
|
||||
val llmCalls: Long = 0,
|
||||
val subagentEvents: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/observability/trace?limit=N` 单个 span。
|
||||
* `kind` / `status` 是开放集合,保留原始字符串(不建枚举)。
|
||||
* `startedAt` / `endedAt` 为 RFC3339;`props` / `events` 为任意结构,原样透传。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminTraceSpanDto(
|
||||
val id: String,
|
||||
val parentId: String? = null,
|
||||
val name: String = "",
|
||||
val kind: String = "",
|
||||
val status: String = "",
|
||||
val startedAt: String = "",
|
||||
val endedAt: String? = null,
|
||||
val durationNs: Long = 0,
|
||||
val props: Map<String, JsonElement>? = null,
|
||||
val events: List<AdminTraceEventDto>? = null,
|
||||
)
|
||||
|
||||
/** `trace.events[]` 一项;`time` 为 RFC3339(纳秒精度)。 */
|
||||
@Serializable
|
||||
data class AdminTraceEventDto(
|
||||
val time: String = "",
|
||||
val type: String = "",
|
||||
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。
|
||||
*/
|
||||
@Serializable
|
||||
data class PersonaSummaryDto(
|
||||
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,
|
||||
val thinking: PersonaThinkingDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatBackgroundDto(
|
||||
val landscape: String? = null,
|
||||
val portrait: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 人格 `thinking` 读侧形状(v0.43)。
|
||||
* [showToUser] 是**三态**:`null` = 未配置(保持现行为),true/false = 显式选择;
|
||||
* 不得把 null 拍平成 false。
|
||||
*/
|
||||
@Serializable
|
||||
data class PersonaThinkingDto(
|
||||
val budgetTokens: Int = 0,
|
||||
val effort: String = "",
|
||||
val showToUser: Boolean? = null,
|
||||
val mode: String = "",
|
||||
)
|
||||
|
||||
/** `GET /api/v1/admin/memories` 一条记忆;`scope` 为开放集合(`user|persona|project`)。 */
|
||||
@Serializable
|
||||
data class MemorySummaryDto(
|
||||
val id: String,
|
||||
val scope: String = "",
|
||||
val text: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/backthink` 一条后台思考记录。
|
||||
* [at] 是 **Unix 毫秒**(与 trace 的 RFC3339 不同源,切勿共用解析)。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminBackthinkItemDto(
|
||||
val id: String,
|
||||
val trigger: String = "",
|
||||
val conclusion: String? = null,
|
||||
val shouldNotify: Boolean = false,
|
||||
val pushed: Boolean = false,
|
||||
val at: Long = 0,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `POST /api/v1/chat` 请求体(契约 v0.56,api-client §1 行 3)。
|
||||
*
|
||||
* 刻意只带这一个端点要的字段——**不复用整条会话对象**:请求体越小,「WS 帧 4096 字节上限」
|
||||
* 的反面(REST 请求体没有长度上限)就越不会被无关字段吃掉,抓包时也一眼能看出这一轮发了什么。
|
||||
*
|
||||
* 字段语义:
|
||||
* - [text] / [image] **至少一项非空**,两项都空服务端回 `400 bad_request`;
|
||||
* - [image] 是 base64 `data:` URI 或 http(s) URL,**解码后上限 5 MiB**
|
||||
* (超限 `400 image_too_large`,文案指路 `POST /api/v1/upload`);
|
||||
* - [clientMsgId] 是客户端自带的幂等键:同一会话内**同一个非空值只承认第一条**,
|
||||
* 重复到达回 `200 {data:{duplicate:true,messageId,sessionId}}` 且不落库、不起回合;
|
||||
* - [stream] 必须为 `true`(本端**不用** `stream:false`:它是同步的,服务端没有写超时,
|
||||
* 客户端 60s 读超时会先超时、而回合还在跑——重发就是第二次对话)。
|
||||
*
|
||||
* ⚠️ 本项目 `ApiClientFactory.json` 用 kotlinx 默认的 `encodeDefaults = false`:
|
||||
* 取值等于默认值的字段**整键省略**。因此**纯图片消息**(`text == ""`)线上不会带 `text` 键——
|
||||
* 服务端 Go 侧零值同为空串,语义完全等价(已按此实现,见 [ChatDtoRealPayloadTest])。
|
||||
*
|
||||
* [documents] 同理:默认空列表 ⇒ **不带附件的请求线上与加入该字段之前逐字节相同**
|
||||
* (服务端 `Documents []chatDocumentRef \`json:"documents,omitempty"\`` 零值也省略,
|
||||
* 见 `DocumentDtoTest` 里拿字面量 JSON 对比的那条钉子)。
|
||||
*/
|
||||
@Serializable
|
||||
data class ChatRequest(
|
||||
val sessionId: String? = null,
|
||||
val text: String = "",
|
||||
val image: String? = null,
|
||||
val deviceId: String? = null,
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/v1/chat` 的**受理回执**(`data` 载荷)。v0.56 起响应面不再是「任何情况都 200」,
|
||||
* 四种判定各有形状,因此这里**全部字段可空**、按组合判读,绝不假设某字段恒在:
|
||||
*
|
||||
* | 判定 | HTTP | 载荷 |
|
||||
* |---|---|---|
|
||||
* | 已起回合 | 200 | `{stream:true}` |
|
||||
* | 正忙已续接排队 | 202 | `{stream:true,queued:true,queueDepth:n}` |
|
||||
* | 幂等键重复 | 200 | `{duplicate:true,messageId,sessionId}` |
|
||||
*
|
||||
* 其余三种(`409 chat_busy` / `500 chat_unavailable` / `503 chat_dispatch_failed`)是
|
||||
* `{error:{code,message}}` 信封,由 [top.yeij.cyrene.data.repository.ChatRepository] 走错误路径分类。
|
||||
*
|
||||
* 不开枚举:服务端还有别的判定会在将来加进来,未知组合一律按「已受理但形状不认识」处理,
|
||||
* 不因为不认识一个布尔而把整轮当失败。
|
||||
*/
|
||||
@Serializable
|
||||
data class ChatAckDto(
|
||||
val stream: Boolean? = null,
|
||||
val queued: Boolean? = null,
|
||||
val queueDepth: Int? = null,
|
||||
val duplicate: Boolean? = null,
|
||||
val messageId: String? = null,
|
||||
val sessionId: String? = 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,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginResponse(
|
||||
val token: String,
|
||||
/** **Unix 秒**(不是毫秒):`time.Now().Add(24h).Unix()`。 */
|
||||
val expiresAt: Long,
|
||||
val role: String,
|
||||
/** B2 的长期凭据;服务端没接线 refresh 存储时整个键缺失。 */
|
||||
val refreshToken: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /api/v1/auth/refresh` 的请求体(公开接口,凭据就在请求体里)。 */
|
||||
@Serializable
|
||||
data class RefreshRequest(
|
||||
val refreshToken: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/v1/auth/refresh` 的成功载荷。
|
||||
*
|
||||
* - [refreshToken] 在默认(一次性轮换)模式下是**新的**凭据,旧的同一笔交易里作废;
|
||||
* `AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发,此时旧凭据继续有效——所以这里必须可空,
|
||||
* 调用方也要在缺失时保留原值。
|
||||
* - [role] 官方文档漏写,但实现一定会返回;给默认值以免文档形状的响应解不动。
|
||||
*/
|
||||
@Serializable
|
||||
data class RefreshResponse(
|
||||
val token: String,
|
||||
/** **Unix 秒**。 */
|
||||
val expiresAt: Long,
|
||||
val role: String = "",
|
||||
val refreshToken: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /api/v1/auth/logout` 的请求体。 */
|
||||
@Serializable
|
||||
data class LogoutRequest(
|
||||
val refreshToken: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `/auth/logout` 的响应体。文档与实现互相矛盾(`{"ok":true,"revokedCount":n}` / `{"revokedCount":n}`
|
||||
* 对上真实信封里的 `{"revoked":bool}`),而本端**只看 HTTP 2xx**——所以每个字段都给默认值,
|
||||
* 三种形状都能宽容解出,解不动也不影响登出。
|
||||
*/
|
||||
@Serializable
|
||||
data class LogoutResponse(
|
||||
val revoked: Boolean = false,
|
||||
val revokedCount: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `GET /api/v1/sessions/{id}/messages` 单条消息(api-reference §3.4)。
|
||||
* 服务端按 `createdAt` 倒序返回,渲染前需 reverse / merge。
|
||||
*
|
||||
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
|
||||
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
|
||||
*/
|
||||
@Serializable
|
||||
data class HistoryMessageDto(
|
||||
val id: String,
|
||||
val sessionId: String,
|
||||
val role: String,
|
||||
val content: String = "",
|
||||
val mode: String? = null,
|
||||
val mood: String? = null,
|
||||
val image: String? = null,
|
||||
val thinking: String? = null,
|
||||
val platform: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val senderName: String? = null,
|
||||
val adapterId: String? = null,
|
||||
/**
|
||||
* 客户端幂等键(契约 v0.56 §3.13,`omitempty`):带 `clientMsgId` 发出的 user 消息会被
|
||||
* 服务端**原样回填**,`GET /messages` / WS `history` / `message_persisted` 都能看到。
|
||||
* 客户端据此把本地乐观气泡对齐到服务端真相——取代「靠文本相等」的脆弱匹配。
|
||||
* 空 = 这条消息没有键(老客户端发的、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,
|
||||
)
|
||||
|
||||
/**
|
||||
* `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,29 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `GET /api/v1/meta/enums` 表内一项(api-client §1.1)。
|
||||
* [id] 是**唯一可比对**的值(契约 id 为小写、大小写敏感);[label] 只是显示默认值,
|
||||
* 自带 i18n 的客户端可以覆盖。
|
||||
*/
|
||||
@Serializable
|
||||
data class EnumItemDto(
|
||||
val id: String = "",
|
||||
val label: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* `platform` / `mood` / `mode` 的共享枚举权威表——客户端**不必硬编码**即可拿到稳定映射。
|
||||
*
|
||||
* 字段全部带默认值:只下发部分表(或 `null`)时仍能解码,缺失的那一类按「表内无此项」
|
||||
* 走原样回落,而不是让整次解码失败。
|
||||
* [unknown] 是机器可读的未知值回落约定,当前恒为 `"display-raw"`(原样显示,见 §1.1 红线)。
|
||||
*/
|
||||
@Serializable
|
||||
data class EnumTableDto(
|
||||
val platforms: List<EnumItemDto> = emptyList(),
|
||||
val moods: List<EnumItemDto> = emptyList(),
|
||||
val modes: List<EnumItemDto> = emptyList(),
|
||||
val unknown: 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,
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `GET /api/v1/sessions` 持久化会话摘要(api-reference §3.2 A)。
|
||||
*
|
||||
* 主会话 `id` 恒为 `"web"`、`type = "main"`,但**不恒在首位**:
|
||||
* 服务端排序是 `pinned DESC, type = 'main' DESC, created_at ASC`,
|
||||
* 被置顶的次级会话合法地排在主会话前面。
|
||||
*
|
||||
* [title] 在会话还没有任何用户消息时**整个键缺席**(自动命名只在那时写入)——
|
||||
* 这是正常状态,不是异常;渲染时用客户端兜底标题。
|
||||
*/
|
||||
@Serializable
|
||||
data class SessionSummaryDto(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val title: String? = null,
|
||||
val createdAt: Long,
|
||||
val pinned: Boolean = false,
|
||||
/** 会话还没有消息时**整个键缺席**。 */
|
||||
val lastMessage: SessionLastMessageDto? = null,
|
||||
/**
|
||||
* 未读条数(D4,契约 v0.37 起**恒下发**):`createdAt >` 该会话已读游标的消息条数,
|
||||
* `0` = 无未读。
|
||||
*
|
||||
* 有默认值是为了**旧快照仍能解码**:[top.yeij.cyrene.core.prefs.SessionPreferences]
|
||||
* 把整个列表原样持久化成 JSON,非默认值会让升级后第一次启动的整份快照解码失败,
|
||||
* 临时失效(列表先空、等 REST 回来)——那正是本应用最不该出现的表现。
|
||||
* 默认 `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
|
||||
|
||||
companion object {
|
||||
const val MAIN_SESSION_ID = "web"
|
||||
const val TYPE_MAIN = "main"
|
||||
const val TYPE_SUB = "sub"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `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,
|
||||
)
|
||||
|
||||
/**
|
||||
* 会话最后一条消息的摘要(服务端 `historyLastMessage`)。
|
||||
*
|
||||
* [content] 已被服务端折叠空白、截断到 60 个字符 + `…`,但**保留原始的 `<p>` / `<action>` 标记**:
|
||||
* 直接拿来显示会露出标签,渲染前必须剥掉。
|
||||
*/
|
||||
@Serializable
|
||||
data class SessionLastMessageDto(
|
||||
val role: String,
|
||||
val content: String = "",
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* `PATCH /api/v1/sessions/{id}` 的请求体。
|
||||
*
|
||||
* 服务端逐字段用指针判断:**字段缺席即保留原值**,没有「省略即清空」的陷阱;
|
||||
* 因此这里也必须可空,并且不能被序列化成显式 `null`——
|
||||
* 生产 `Json` 的 `explicitNulls = false` 正是保证这一点的地方。
|
||||
*
|
||||
* **绝不能加 `personaId`**:服务端对任何值的 `personaId` 都先判 400。
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateSessionRequest(
|
||||
val title: String? = null,
|
||||
val pinned: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `PATCH /api/v1/sessions/{id}` 的响应是**局部**形状(`{id, title?}` 和/或 `{id, pinned?}`),
|
||||
* 不是完整的会话行——绝不能拿它当列表行用。
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateSessionResponse(
|
||||
val id: String = "",
|
||||
val title: String? = null,
|
||||
val pinned: Boolean? = null,
|
||||
)
|
||||
|
||||
/** `DELETE /api/v1/sessions/{id}` → `200 {"data":{"deleted":"<id>"}}`。 */
|
||||
@Serializable
|
||||
data class DeleteSessionResponse(
|
||||
val deleted: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/v1/sessions/{id}/read`(D4)的请求体:可选地把已读游标推进到 `before`。
|
||||
*
|
||||
* - **键缺席 = 「现在」**(「我看到的都算已读」)。生产 `Json` 的 `explicitNulls = false`
|
||||
* 保证 `before = null` 时整个键缺席,序列化出来是 `{}`——服务端明确接受空对象
|
||||
* (`api-reference §3.6b`:**空 body / `{}` = 现在**)。
|
||||
* - 服务端对 `0` / 负数 / 非数字一律 `400`,所以**绝不能**把 `0` 发上去:调用方拿到
|
||||
* 非正的「没有时间戳」时必须折成缺席(null),而不是原样透传。
|
||||
* - 游标**只前进**(SQL `MAX(read_at, before)`),重复 / 乱序 / 重试都幂等安全。
|
||||
*/
|
||||
@Serializable
|
||||
data class ReadSessionRequest(
|
||||
val before: Long? = null,
|
||||
)
|
||||
|
||||
/** `POST /api/v1/sessions/{id}/read` → `200 {"data":{"sessionId":"<id>","readAt":<毫秒>}}`。 */
|
||||
@Serializable
|
||||
data class ReadSessionResponse(
|
||||
val sessionId: String = "",
|
||||
/** 推进**后**的游标(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。 */
|
||||
val readAt: Long = 0L,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `POST /api/v1/upload` 的成功载荷(api-client §1 行 13,v0.24 起的 B1 端点)。
|
||||
*
|
||||
* 线上形状(`internal/services/gateway/upload.go` 的 `uploadResponse`,字段名逐字如此):
|
||||
* ```
|
||||
* 201 {"data":{"url":"http://192.168.1.10:10942/api/v1/files/<32hex>.png","size":1234,"mime":"image/png","name":"<32hex>.png"}}
|
||||
* ```
|
||||
*
|
||||
* 语义要点:
|
||||
* - [url] 是**绝对同源地址**(`scheme://host/api/v1/files/<name>`),**可直接作 `chat.image`**:
|
||||
* 视觉层只把 `http(s)://` 开头当 URL,裸路径会被当成 base64 —— 所以这个字段必须按 URL 校验,
|
||||
* 不能原样塞回去(见 `UploadRepository`);
|
||||
* - [name] 是服务端生成的 **32 位小写 hex + 白名单扩展名**,客户端原始文件名**被丢弃**(不可信输入);
|
||||
* - [size] 字节数与 [mime] 都是**服务端嗅探/统计的结果**,不是客户端声明的——客户端传的
|
||||
* `Content-Type` 一律不被采信(`sniffImageMIME`)。
|
||||
*
|
||||
* 全部字段带默认值:解码器是生产同一份 `Json`(`ignoreUnknownKeys` / `coerceInputValues`),
|
||||
* 将来服务端加键不会让整次上传变成解析失败。**缺 `url`(或 `url` 不是绝对 http(s))仍要判失败**——
|
||||
* 那是「上传成功但拿不到可用地址」,由仓库层拦,绝不把空串/路径当 URL 发出去。
|
||||
*/
|
||||
@Serializable
|
||||
data class UploadResponse(
|
||||
val url: String = "",
|
||||
val size: Long = 0,
|
||||
val mime: String = "",
|
||||
val name: String = "",
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
package top.yeij.cyrene.data.image
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import top.yeij.cyrene.domain.chat.ChatImageBudget
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* 一次重编码的产物。
|
||||
*
|
||||
* [dataUri] 与 [bytes] 是**同一张图**的两种形态:内联(base64 `data:` URI)用前者,
|
||||
* 上传(`POST /api/v1/upload`)用后者。上传时**不能**从 [dataUri] 反解一遍——那既多一次
|
||||
* 解码与分配,也让「上传的字节」和「内联的字节」有漂移的余地。
|
||||
*/
|
||||
class PreparedImage(
|
||||
val dataUri: String,
|
||||
val bytes: ByteArray,
|
||||
val mime: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 把用户选的本地图片压成可以内联发送的 `data:` URI(**发送前的必经一步**)。
|
||||
*
|
||||
* 为什么要重编码而不是直接读原始字节:服务端把 `image` 字符串**原样落库**,
|
||||
* 每一页历史与每一次 WS `history` 回放都要重传它(协同文档 G3)。所以这里
|
||||
* 无论用户选了什么,出口都保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES]:
|
||||
*
|
||||
* 1. 先按边界解码拿原尺寸 → 等比缩到最长边 [ChatImageBudget.MAX_DIMENSION_PX];
|
||||
* 2. 编码(有 alpha 用 PNG,否则用 JPEG);
|
||||
* 3. 仍超预算 → **尺寸减半重来**(最多 [MAX_ATTEMPTS] 档);
|
||||
* 4. 出口再按服务端的**字符串长度**上限复核一次(4/3 膨胀 + 前缀余量)。
|
||||
*
|
||||
* 任何一步失败(读不到 / 解不出 / 编不出 / 压不到预算内)都返回 `null`——由调用方回滚并报错,
|
||||
* **绝不**退回「原图 base64」:那既会撞服务端 5 MiB 硬顶,也会把历史背成几 MB。
|
||||
*
|
||||
* 产物同时给出 `data:` URI 与**同一份**字节:**内联还是上传由调用方按体积定**
|
||||
* (见 `ChatImageDelivery`)——本对象只管「编到预算内」,通道选择是另一件事,而且它需要
|
||||
* 编完之后的真实字节数才谈得上。
|
||||
*
|
||||
* 预算数学在 [ChatImageBudget](纯函数,可单测);本对象只剩「必须真机才有」的解码与编码。
|
||||
*/
|
||||
object ChatImagePreparer {
|
||||
|
||||
private const val JPEG_QUALITY = 80
|
||||
|
||||
/** 缩不动就减半重来的次数上限(1 MiB 预算下 1280px 起步一般 1–2 次就够)。 */
|
||||
private const val MAX_ATTEMPTS = 4
|
||||
|
||||
/**
|
||||
* @return 可内联发送的 `data:` URI 与可上传的同一份字节;失败返回 `null`。
|
||||
* 必须在后台线程调用(解码 + 缩放 + 编码都是重活)。
|
||||
*/
|
||||
fun prepare(context: Context, uri: Uri): PreparedImage? {
|
||||
val raw = readBytes(context, uri) ?: return null
|
||||
val decoded = decodeScaled(raw) ?: return null
|
||||
return try {
|
||||
encodeWithinBudget(decoded)
|
||||
} finally {
|
||||
decoded.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBytes(context: Context, uri: Uri): ByteArray? =
|
||||
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
|
||||
|
||||
/** 按**目标尺寸**两步解码:先读边界算 `inSampleSize`(省内存),再精确缩放到目标。 */
|
||||
private fun decodeScaled(raw: ByteArray): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(raw, 0, raw.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val target = ChatImageBudget.scaledSize(bounds.outWidth, bounds.outHeight)
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, target.width, target.height)
|
||||
}
|
||||
val sampled = runCatching { BitmapFactory.decodeByteArray(raw, 0, raw.size, options) }.getOrNull()
|
||||
?: return null
|
||||
if (sampled.width == target.width && sampled.height == target.height) return sampled
|
||||
|
||||
val scaled = runCatching {
|
||||
Bitmap.createScaledBitmap(sampled, target.width, target.height, true)
|
||||
}.getOrNull() ?: return null
|
||||
if (scaled !== sampled) sampled.recycle()
|
||||
return scaled
|
||||
}
|
||||
|
||||
/** 在不小于目标尺寸的前提下,取最大的 2 的幂作为采样步长。 */
|
||||
private fun sampleSizeFor(width: Int, height: Int, targetWidth: Int, targetHeight: Int): Int {
|
||||
var sample = 1
|
||||
while (width / (sample * 2) >= targetWidth && height / (sample * 2) >= targetHeight) {
|
||||
sample *= 2
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
/** 逐档降尺寸,直到编码后字节数落进预算;都落不进返回 `null`。 */
|
||||
private fun encodeWithinBudget(source: Bitmap): PreparedImage? {
|
||||
var width = source.width
|
||||
var height = source.height
|
||||
repeat(MAX_ATTEMPTS) { attempt ->
|
||||
val frame = if (attempt == 0) source else scale(source, width, height) ?: return null
|
||||
// 有 alpha 的图走 JPEG 会把透明区压成黑块,所以这类图保持 PNG(无损、体积更大)。
|
||||
val mime = if (frame.hasAlpha()) MIME_PNG else MIME_JPEG
|
||||
val bytes = compress(frame, mime)
|
||||
if (frame !== source) frame.recycle()
|
||||
|
||||
if (ChatImageBudget.withinTarget(bytes.size)) {
|
||||
val dataUri = ChatImageBudget.dataUri(mime, Base64.encodeToString(bytes, Base64.NO_WRAP))
|
||||
if (!ChatImageBudget.withinServerLimit(dataUri.length)) return null
|
||||
return PreparedImage(dataUri = dataUri, bytes = bytes, mime = mime)
|
||||
}
|
||||
width = (width / 2).coerceAtLeast(1)
|
||||
height = (height / 2).coerceAtLeast(1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun scale(source: Bitmap, width: Int, height: Int): Bitmap? =
|
||||
runCatching { Bitmap.createScaledBitmap(source, width, height, true) }.getOrNull()
|
||||
|
||||
private fun compress(bitmap: Bitmap, mime: String): ByteArray {
|
||||
val format = if (mime == MIME_PNG) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
|
||||
val out = ByteArrayOutputStream()
|
||||
bitmap.compress(format, JPEG_QUALITY, out)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private const val MIME_JPEG = "image/jpeg"
|
||||
private const val MIME_PNG = "image/png"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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
|
||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 管理只读监控取数层(api-client §2)。
|
||||
* 权限判断不在这里:仓库只管取数,403 由调用方按 [top.yeij.cyrene.domain.admin.AdminFailure] 收敛。
|
||||
*/
|
||||
class AdminRepository(
|
||||
private val api: AdminApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
suspend fun metrics(): Result<AdminMetricsDto> = withContext(dispatcher) {
|
||||
apiCall { api.metrics() }
|
||||
}
|
||||
|
||||
/** `GET /observability/trace`;`limit` 与服务端对齐,钳制在 [1, [MAX_TRACE_LIMIT]]。 */
|
||||
suspend fun trace(limit: Int = DEFAULT_TRACE_LIMIT): Result<List<AdminTraceSpanDto>> =
|
||||
withContext(dispatcher) {
|
||||
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() }
|
||||
}
|
||||
|
||||
suspend fun memories(): Result<List<MemorySummaryDto>> = withContext(dispatcher) {
|
||||
apiCall { api.memories() }
|
||||
}
|
||||
|
||||
suspend fun backthink(): Result<List<AdminBackthinkItemDto>> = withContext(dispatcher) {
|
||||
apiCall { api.backthink() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** trace 拉取条数:够看近期活动,又不会把一屏塞满。 */
|
||||
const val DEFAULT_TRACE_LIMIT = 100
|
||||
|
||||
/** 服务端 `?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,102 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
import top.yeij.cyrene.data.api.ChatApi
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.ChatAckDto
|
||||
import top.yeij.cyrene.data.api.dto.ChatRequest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* `POST /api/v1/chat` 的受理判定(契约 v0.56)。
|
||||
*
|
||||
* 它是一个**密封结果**而不是 `Result.failure`:v0.56 起「会话正忙」与「排队已满」不再是异常,
|
||||
* 而是编排器的正常回执(修复前 handler 提前 `return 200`,这两种状态**永远不可能出现**)。
|
||||
* 把它们和网络错误混在同一条失败流里,调用方就只能靠字符串猜——这正是本类型要消除的东西。
|
||||
*/
|
||||
sealed interface ChatAck {
|
||||
|
||||
/** `200 {stream:true}`:回合已起,内容帧走**已连接的 WS**。 */
|
||||
data object Accepted : ChatAck
|
||||
|
||||
/** `202 {stream:true,queued:true,queueDepth:n}`:会话正忙,这条已续接排队(**已落库**)。 */
|
||||
data class Queued(val queueDepth: Int) : ChatAck
|
||||
|
||||
/** `200 {duplicate:true,messageId,sessionId}`:幂等键重复,服务端**没有**再落库 / 再起回合。 */
|
||||
data class Duplicate(val messageId: String?, val sessionId: String?) : ChatAck
|
||||
|
||||
/** `409 {error:{code:"chat_busy"}}`:待处理队列已满,这条**没有被受理、没有落库**。 */
|
||||
data object QueueFull : ChatAck
|
||||
|
||||
/**
|
||||
* `500 chat_unavailable` / `503 chat_dispatch_failed`:服务端接不住这一轮。
|
||||
* [code] 保留原始错误码——两者的区别对排查有用,对用户文案无差别。
|
||||
*/
|
||||
data class Unavailable(val code: String, val message: String) : ChatAck
|
||||
|
||||
/** 其余明确的拒绝(`400 bad_request` / `400 image_too_large` / 未知码)。 */
|
||||
data class Rejected(val code: String, val message: String) : ChatAck
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送一条消息(REST 通道)。
|
||||
*
|
||||
* 与别的仓库不同的一点:**非 2xx 里有一半是业务态而不是失败**(见 [ChatAck]),
|
||||
* 所以这里刻意**不使用** [top.yeij.cyrene.data.api.apiCall]——它把 4xx/5xx 一律折进
|
||||
* `Result.failure`、且只回 `data` 丢掉状态码,于是 `202` 与 `409` 的差别在折进去的那一刻
|
||||
* 就没了。信封解析与错误映射仍走 [ApiErrorMapper],口径与其它端点一致。
|
||||
*
|
||||
* `Result.failure` 只留给**真正发不出去**的情况:网络 / 超时 / 解析不出来 / 401。
|
||||
*/
|
||||
class ChatRepository(
|
||||
private val api: ChatApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
suspend fun send(request: ChatRequest): Result<ChatAck> = withContext(dispatcher) {
|
||||
try {
|
||||
val response = api.sendChat(request)
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null -> Result.success(ackOf(data))
|
||||
// 2xx 却没有 data:与 apiCall 同一口径,判解析失败而不是「已受理」。
|
||||
response.isSuccessful -> Result.failure(parseFailure())
|
||||
else -> {
|
||||
val error = ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string())
|
||||
val ack = serviceAck(error)
|
||||
if (ack != null) Result.success(ack) else Result.failure(CyreneException(error))
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ackOf(data: ChatAckDto): ChatAck = when {
|
||||
data.duplicate == true -> ChatAck.Duplicate(data.messageId, data.sessionId)
|
||||
data.queued == true -> ChatAck.Queued(data.queueDepth ?: 0)
|
||||
// `{stream:true}` 以及将来新增的未知组合:受理了、形状不认识 → 按已受理处理。
|
||||
else -> ChatAck.Accepted
|
||||
}
|
||||
|
||||
/** 非 2xx 里属于**编排器真实回执**的部分;返回 null = 真失败(401 或未知服务端错误)。 */
|
||||
private fun serviceAck(error: CyreneError): ChatAck? {
|
||||
if (error is CyreneError.Unauthorized) return null
|
||||
val code = (error as? CyreneError.Server)?.code.orEmpty()
|
||||
val message = error.userMessage
|
||||
return when (code) {
|
||||
"chat_busy" -> ChatAck.QueueFull
|
||||
"chat_unavailable", "chat_dispatch_failed" -> ChatAck.Unavailable(code, message)
|
||||
else -> ChatAck.Rejected(code, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFailure() =
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse))
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,192 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 会话消息层:分页 / 搜索 / **正向增量**读取,以及单条消息的**编辑 / 删除**(D3)。
|
||||
*
|
||||
* 三条读取出口共用同一个端点,只是给的查询参数不同:
|
||||
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
|
||||
* - [search]:`q` 会话内搜索(也是倒序);
|
||||
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6)。
|
||||
*
|
||||
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
|
||||
*/
|
||||
class MessageRepository(
|
||||
private val api: SessionsApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 拉一页消息。
|
||||
*
|
||||
* @param before 上一页最旧消息的 `createdAt`(用 [PagedMessages.oldestCreatedAt]);
|
||||
* null = 最新一页
|
||||
* @param platform 来源平台过滤;null = 全量(Web 端共享主会话语义)
|
||||
*/
|
||||
suspend fun loadPage(
|
||||
sessionId: String,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
before: Long? = null,
|
||||
platform: String? = null,
|
||||
): Result<PagedMessages> = withContext(dispatcher) {
|
||||
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
|
||||
apiCall { api.listMessages(sessionId, pageSize, before, platform) }
|
||||
.map { raw -> toPagedMessages(raw, pageSize) }
|
||||
}
|
||||
|
||||
/** 首屏 / 刷新:最新一页。 */
|
||||
suspend fun loadLatest(
|
||||
sessionId: String,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
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` 去重、并在游标没前进时停下来,不在这里兜。
|
||||
*/
|
||||
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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑单条消息(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
|
||||
|
||||
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(
|
||||
raw: List<HistoryMessageDto>,
|
||||
pageSize: Int,
|
||||
): PagedMessages {
|
||||
val ascending = raw.sortedWith(MessageOrder)
|
||||
return PagedMessages(
|
||||
messages = ascending,
|
||||
hasMore = raw.size >= pageSize,
|
||||
oldestCreatedAt = ascending.firstOrNull()?.createdAt,
|
||||
newestCreatedAt = ascending.lastOrNull()?.createdAt,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.data.api.MetaApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.EnumTableDto
|
||||
|
||||
/**
|
||||
* 枚举权威表取数层(api-client §1.1)。
|
||||
* 仓库不持有持久化状态:调用方失败时保留上次快照(见 [top.yeij.cyrene.core.prefs.EnumPreferences])。
|
||||
*/
|
||||
class MetaRepository(
|
||||
private val api: MetaApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
suspend fun enums(): Result<EnumTableDto> = withContext(dispatcher) {
|
||||
apiCall { api.enums() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
|
||||
/**
|
||||
* 一页消息(已由仓库转成 **升序**,可直接追加渲染)。
|
||||
*
|
||||
* @param hasMore 该页是否可能还有更旧的消息(`原始页大小 == 请求页大小` 的保守判断)
|
||||
* @param oldestCreatedAt 本页最旧一条的 `createdAt`,作为下一页的 `before` 游标
|
||||
* @param newestCreatedAt 本页最新一条的 `createdAt`
|
||||
*/
|
||||
data class PagedMessages(
|
||||
val messages: List<HistoryMessageDto>,
|
||||
val hasMore: Boolean,
|
||||
val oldestCreatedAt: Long?,
|
||||
val newestCreatedAt: Long?,
|
||||
)
|
||||
|
||||
/** 消息排序:`createdAt` 升序,同毫秒用 `id` 兜底,保证稳定可复现。 */
|
||||
internal val MessageOrder: Comparator<HistoryMessageDto> = compareBy<HistoryMessageDto>({ it.createdAt }, { it.id })
|
||||
|
||||
/**
|
||||
* 纯函数合并:`existing`(本地已加载,任意序)+ `incoming`(服务端新到的一页,倒序)
|
||||
* → 按 `id` 去重、按 `createdAt` 升序(同毫秒用 `id` 兜底)的结果。
|
||||
*
|
||||
* 冲突时 **incoming 胜出**(§3.2「服务端优先」)。不修改入参,返回新列表。
|
||||
* 时间复杂度 O((n+m) log(n+m)),空间 O(n+m)。
|
||||
*/
|
||||
fun mergeMessages(
|
||||
existing: List<HistoryMessageDto>,
|
||||
incoming: List<HistoryMessageDto>,
|
||||
): List<HistoryMessageDto> {
|
||||
if (incoming.isEmpty()) return existing.sortedWith(MessageOrder)
|
||||
if (existing.isEmpty()) return incoming.sortedWith(MessageOrder)
|
||||
|
||||
val byId = LinkedHashMap<String, HistoryMessageDto>(existing.size + incoming.size)
|
||||
existing.forEach { byId[it.id] = it }
|
||||
incoming.forEach { byId[it.id] = it }
|
||||
return byId.values.sortedWith(MessageOrder)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一帧 `history` 收敛到**本会话**。
|
||||
*
|
||||
* WS 首帧 `history` 是**主会话**最近 50 条的回放,但连接是全局的、帧会被投给任意
|
||||
* `ChatViewModel`——不过滤就会把主会话的消息混进次级会话。
|
||||
*
|
||||
* 判定以**消息自身的 `sessionId`** 为准,而不是「只有主会话才收 history」:前者更耐将来,
|
||||
* 服务端哪天开始给次级会话回放,这里无需改动。
|
||||
*
|
||||
* `sessionId` 为空的帧视为「无法判定」**放行**——不因缺一个字段就整段丢掉历史(保持既有行为)。
|
||||
*
|
||||
* 纯函数,不修改入参。
|
||||
*/
|
||||
fun historyForSession(
|
||||
messages: List<HistoryMessageDto>,
|
||||
sessionId: String,
|
||||
): List<HistoryMessageDto> = messages.filter { it.sessionId.isBlank() || it.sessionId == sessionId }
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 会话列表 / 新建 / 重命名置顶 / 删除的数据获取层。
|
||||
*
|
||||
* 列表策略(§3.1):调用方拿到结果后**整体替换**本地快照,不做 merge;
|
||||
* 失败时调用方保留旧快照。仓库本身不持有任何持久化状态。
|
||||
*/
|
||||
class SessionRepository(
|
||||
private val api: SessionsApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* `GET /sessions`,原样返回服务端顺序——**不做客户端排序**:
|
||||
* `pinned DESC, type='main' DESC, created_at ASC`,置顶的次级会话合法地排在主会话前面。
|
||||
*/
|
||||
suspend fun listSessions(): Result<List<SessionSummaryDto>> = withContext(dispatcher) {
|
||||
apiCall { api.listSessions() }
|
||||
}
|
||||
|
||||
/**
|
||||
* `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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* `PATCH /sessions/{id}`:只发本次要改的字段,缺席的字段服务端保持原值
|
||||
* (所以 `null` 是「不改」,不是「清空」——服务端也无法把标题清回自动命名)。
|
||||
*/
|
||||
suspend fun updateSession(
|
||||
sessionId: String,
|
||||
title: String? = null,
|
||||
pinned: Boolean? = null,
|
||||
): Result<UpdateSessionResponse> = withContext(dispatcher) {
|
||||
apiCall { api.updateSession(sessionId, UpdateSessionRequest(title = title, pinned = pinned)) }
|
||||
}
|
||||
|
||||
/** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */
|
||||
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
|
||||
apiCall { api.deleteSession(sessionId) }.map { it.deleted }
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /sessions/{id}/read`(D4):推进该会话的已读游标,成功返回**推进后**的游标。
|
||||
*
|
||||
* @param before 毫秒时间戳;**null = 现在**(「我看到的都算已读」)。服务端对 `0` / 负数
|
||||
* 一律 `400`,所以调用方必须把「没有时间戳」表达成 null 而不是 0。
|
||||
*
|
||||
* 这是**幂等**的(服务端 `MAX(read_at, before)`,只前进),失败也没有副作用——
|
||||
* 唯一真源仍是服务端游标,漏标一次会在下一次 `GET /sessions` 自然暴露、再被纠正。
|
||||
*/
|
||||
suspend fun markRead(sessionId: String, before: Long? = null): Result<ReadSessionResponse> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.markRead(sessionId, ReadSessionRequest(before = before)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
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.CyreneException
|
||||
import top.yeij.cyrene.data.api.UploadApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
|
||||
/**
|
||||
* 一次成功上传的可用形态。
|
||||
*
|
||||
* 调用方(`ChatViewModel`)只需要 [url],其余三个字段由服务端生成、留着排查用:
|
||||
* [size] 是服务端统计的字节数、[mime] 是**服务端嗅探**出的类型(不是客户端声明的)、
|
||||
* [name] 是服务端存储名(客户端原始文件名已被丢弃)。
|
||||
*/
|
||||
data class UploadedImage(
|
||||
val url: String,
|
||||
val size: Long,
|
||||
val mime: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 图片上传取数层(`POST /api/v1/upload`)。
|
||||
*
|
||||
* ## 为什么这台机器存在
|
||||
* 服务端把 `chat.image` 字符串**原样落库**(api-client §1 行 3 + 协同 G3):内联的 base64
|
||||
* 会在**每一页历史**与**每一次 WS `history` 回放**里重传一遍。上传一次、消息里只留一个
|
||||
* URL,是唯一能让历史不再随图片长大的路。
|
||||
*
|
||||
* ## 错误语义(这里是本仓库与只读仓库的差别)
|
||||
* 用 [apiCall]:它的状态码**不承载额外语义**——`413 payload_too_large` 与
|
||||
* `415 unsupported_media_type` 的机器可读区分都在 `error.code` 里(已对着在线后端实测,
|
||||
* 见 `UploadDtoRealPayloadTest`),不像 `POST /api/v1/chat` 那样必须靠状态码分 `202` / `409`。
|
||||
* 于是网络 / 超时 / `401` / 服务端拒绝一律折进 `Result.failure`,调用方按 `code` 或
|
||||
* [CyreneError] 的类型分支。
|
||||
*
|
||||
* ## 两处**客户端**必须自己把关的地方(服务端帮不了)
|
||||
* 1. **`url` 必须是绝对 http(s) 地址**:`chat.image` 的视觉层只把 `http(s)://` 当 URL
|
||||
* (`vision.ParseImageRef` 的判据就是 `strings.HasPrefix(src, "http://")`),裸路径
|
||||
* (如 `/api/v1/files/x.png`)会被当成 **base64** 载荷。拿到这种 url 还照发,就是把这一轮
|
||||
* 丢进「图片解析失败」的黑洞——所以这里直接判解析失败,宁可当场重试。
|
||||
* 2. **`url` 不能为空**:`201` 却没有可用地址 = 上传了但拿不到东西,同样判失败,**绝不**
|
||||
* 退回内联 base64(那正是本任务要消灭的成本)。
|
||||
*/
|
||||
class UploadRepository(
|
||||
private val api: UploadApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 上传一张**已重编码**的图片,返回可作 `chat.image` 的绝对 URL。
|
||||
*
|
||||
* [bytes] 与 [mime] 必须来自同一次编码(`ChatImagePreparer` 的产物):
|
||||
* 服务端按**字节嗅探**判类型,声明的 `Content-Type` 只作参考,两者不一致时以字节为准。
|
||||
*/
|
||||
suspend fun uploadImage(bytes: ByteArray, mime: String): Result<UploadedImage> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.upload(partOf(bytes, mime)) }.fold(
|
||||
onSuccess = { data ->
|
||||
val url = absoluteHttpUrl(data.url)
|
||||
if (url == null) {
|
||||
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
} else {
|
||||
Result.success(
|
||||
UploadedImage(url = url, size = data.size, mime = data.mime, name = data.name),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart 部件。
|
||||
*
|
||||
* **部件名固定 `file`**(服务端 `FormFile("file")`)。文件名也**必须给**:Go 的
|
||||
* `mime/multipart` 只把带 `filename` 参数的部件当**文件**,不带的会落进普通表单值、
|
||||
* 于是 `FormFile` 找不到它。扩展名与服务端白名单一致(服务端丢弃原始名,这里只是让
|
||||
* 线上的部件看起来像一张真图,中间件不会因为怪名字做多余判断)。
|
||||
*/
|
||||
private fun partOf(bytes: ByteArray, mime: String): MultipartBody.Part =
|
||||
MultipartBody.Part.createFormData(
|
||||
name = UPLOAD_FIELD_NAME,
|
||||
filename = fileNameFor(mime),
|
||||
body = bytes.toRequestBody(mime.toMediaTypeOrNull()),
|
||||
)
|
||||
|
||||
private fun fileNameFor(mime: String): String = "upload." + when (mime) {
|
||||
MIME_PNG -> "png"
|
||||
MIME_GIF -> "gif"
|
||||
MIME_WEBP -> "webp"
|
||||
MIME_BMP -> "bmp"
|
||||
else -> "jpg"
|
||||
}
|
||||
|
||||
/** 只认绝对 http(s) 地址,且**大小写敏感**(与服务端 `ParseImageRef` 的判据同一口径:放宽只会放行服务端不认的东西)。 */
|
||||
private fun absoluteHttpUrl(raw: String): String? = raw.trim()
|
||||
.takeIf { it.startsWith("http://") || it.startsWith("https://") }
|
||||
}
|
||||
|
||||
private const val UPLOAD_FIELD_NAME = "file"
|
||||
|
||||
private const val MIME_PNG = "image/png"
|
||||
private const val MIME_GIF = "image/gif"
|
||||
private const val MIME_WEBP = "image/webp"
|
||||
private const val MIME_BMP = "image/bmp"
|
||||
@@ -0,0 +1,369 @@
|
||||
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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Cyrene 实时通道客户端(`GET /ws/chat?token=<JWT>`,api-reference §6)。
|
||||
*
|
||||
* ## 用法
|
||||
* 进程内只有**一个**实例,由 `AppContainer.chatSocket` 持有;上层要收帧就取它,**不要自己 new**
|
||||
* (见下面「它不是某个屏幕的私有连接」):
|
||||
* ```
|
||||
* val socket = container.chatSocket
|
||||
* // 收集 socket.events,观察 socket.state;发送用 socket.sendChat / socket.sendInterrupt
|
||||
* ```
|
||||
*
|
||||
* ## 会话归属
|
||||
* 服务端自 v0.36 起在**每个结构化流帧的帧壳**上带 `sessionId`,本类把它原样透传
|
||||
* (见 [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 补差去重。
|
||||
* - 退避 1s/2s/4s/8s/16s/30s(上限 30s,含抖动)。
|
||||
* - 握手返回 **HTTP 401** → 先向 [onAuthRecover] 要一次续期(B2):换到新令牌就**立刻重连一次**;
|
||||
* 明确被拒或重连后仍 401 → [WsState.Offline] 并触发 [onAuthFailed];暂时性失败走普通退避。
|
||||
*
|
||||
* ## 坏帧
|
||||
* 每帧解析都走 [WsEvent.parse],非 JSON / 未知 `type` / 结构不匹配一律丢弃,不会中断连接。
|
||||
*
|
||||
* @param baseUrlProvider 服务器根地址(如 `http://host:8080`),每次重连时重新读取。
|
||||
* @param tokenProvider 当前 JWT,每次重连时重新读取(续期后无需重建实例)。
|
||||
* @param onAuthRecover 握手 401 时的一次性兜底,见 [AuthRecovery]。
|
||||
* @param onAuthFailed 终态回调(令牌被拒且无法续期);在协程内调用,注意切主线程。
|
||||
* @param client 可注入自定义 OkHttpClient(会 `newBuilder` 派生,保留其拦截器);缺省新建。
|
||||
*/
|
||||
class ChatSocket(
|
||||
private val baseUrlProvider: () -> String?,
|
||||
private val tokenProvider: () -> String?,
|
||||
private val onAuthRecover: () -> AuthRecovery = { AuthRecovery.Unrecoverable },
|
||||
private val onAuthFailed: () -> Unit = {},
|
||||
client: OkHttpClient? = null,
|
||||
) {
|
||||
|
||||
private val _state = MutableStateFlow(WsState.Idle)
|
||||
|
||||
/** 连接状态机;见 [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,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/**
|
||||
* 下行事件流。
|
||||
*
|
||||
* **`replay` 恒为 0,这条不是调优而是必需**:重放旧 delta 会让新订阅者重复渲染,会话状态应由
|
||||
* `history` / REST 重建;而连接**进程内共享**之后,新建的 VM(切到一个没访问过的会话)会**立刻**
|
||||
* 订阅同一份流——replay > 0 等于把历史帧投给它,轻则重复渲染,重则会话串台。
|
||||
*
|
||||
* `extraBufferCapacity = 512 + DROP_OLDEST`:发送方是 OkHttp 读线程,不能阻塞;溢出时丢最旧的
|
||||
* 增量帧(服务端发送缓冲 32 本就会丢帧,`stream_end` / `history` 仍可兜底)。
|
||||
*/
|
||||
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
|
||||
|
||||
private val wsClient: OkHttpClient = (client ?: OkHttpClient()).newBuilder()
|
||||
.pingInterval(PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@Volatile
|
||||
private var wantConnection: Boolean = false
|
||||
|
||||
@Volatile
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var attempt: Int = 0
|
||||
|
||||
@Volatile
|
||||
private var sessionJob: Job? = null
|
||||
|
||||
/** 本次 [connect] 是否已经用续期后的令牌重连过一次——避免握手 401 打成死循环。 */
|
||||
@Volatile
|
||||
private var authRetried: Boolean = false
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
/**
|
||||
* 开始连接。**幂等**:已经在连接 / 重连中([wantConnection] 已置位)时直接返回。
|
||||
*
|
||||
* 因为幂等,任何一个 VM 都可以在需要时调它做兜底;但**决定它该不该在**的只有登录态那条路径
|
||||
* (见类注释的硬约束 1),本方法只负责「接上」。
|
||||
*/
|
||||
fun connect() {
|
||||
if (wantConnection) return
|
||||
wantConnection = true
|
||||
attempt = 0
|
||||
authRetried = false
|
||||
sessionJob = ensureScope().launch { runConnectionLoop() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动断开并停掉重连;之后可再次 [connect]。
|
||||
*
|
||||
* ⚠️ 本实例是**进程级共享连接**,不是某个屏幕的私有连接:**只有应用级的登出 / 会话失效路径可以
|
||||
* 调它**(目前唯一调用点:`SessionsViewModel` 的登录态订阅)。ViewModel 的 `onCleared`、
|
||||
* 页面退出、切会话都**不许**调——那会把别的屏幕正在用的连接一起掐断。理由见类注释
|
||||
* 「它不是某个屏幕的私有连接:谁都不许关它」。
|
||||
*/
|
||||
fun close() {
|
||||
wantConnection = false
|
||||
val socket = webSocket
|
||||
webSocket = null
|
||||
socket?.close(CLOSE_CODE_NORMAL, CLOSE_REASON_CLIENT)
|
||||
sessionJob?.cancel()
|
||||
sessionJob = null
|
||||
_state.value = WsState.Idle
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 `chat`(api-reference §6.3)。
|
||||
* @param clientMsgId 幂等键(契约 v0.56 §3.13);重复到达服务端回 `chat_duplicate`,不落库。
|
||||
* @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),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* 发送 `interrupt`(api-client §3.1;契约 v0.56/A2 起支持 `scope`)。
|
||||
*
|
||||
* @param scope 打断范围:`null` = **省略该键**(服务端视同 `"all"`:取消在跑回合 + 清空待处理队列,
|
||||
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
|
||||
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
|
||||
*
|
||||
* **调用方只有一处**:`ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前**
|
||||
* 按 App 偏好(`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数
|
||||
* `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope`
|
||||
* 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。
|
||||
* 返回 false 只有一种原因(WS 未连接):调用方**静默**并不阻断本次发送,理由见那里的 KDoc。
|
||||
*/
|
||||
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
|
||||
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
|
||||
|
||||
private fun send(outbound: WsOutbound): Boolean {
|
||||
if (_state.value != WsState.Connected) return false
|
||||
val socket = webSocket ?: return false
|
||||
return runCatching { socket.send(outbound.toJson()) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
private suspend fun runConnectionLoop() {
|
||||
try {
|
||||
while (wantConnection) {
|
||||
when (val end = runSession()) {
|
||||
SessionEnd.ConfigMissing -> {
|
||||
wantConnection = false
|
||||
_state.value = WsState.Offline
|
||||
}
|
||||
|
||||
SessionEnd.AuthFailed -> {
|
||||
if (!recoverAuth()) break
|
||||
}
|
||||
|
||||
SessionEnd.Closed -> {
|
||||
if (!wantConnection) break
|
||||
_state.value = WsState.Reconnecting
|
||||
delay(backoffDelayMs(attempt))
|
||||
attempt += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
// close() 主动取消,正常退出。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 握手被拒(HTTP 401)后的恢复(B2):
|
||||
* - [AuthRecovery.Renewed]:已换到新令牌,**立刻重连一次**(`tokenProvider` 每次重连都会重新读);
|
||||
* - [AuthRecovery.Unrecoverable]:令牌被服务端拒绝且无法续期 → 终态,触发 [onAuthFailed];
|
||||
* - [AuthRecovery.Transient]:暂时续不上(网络 / 5xx),会话保留,走普通退避重连。
|
||||
*
|
||||
* @return 连接循环是否继续。
|
||||
*/
|
||||
private suspend fun recoverAuth(): Boolean {
|
||||
if (authRetried) return endWithAuthFailure()
|
||||
return when (onAuthRecover()) {
|
||||
is AuthRecovery.Renewed -> {
|
||||
authRetried = true
|
||||
attempt = 0
|
||||
_state.value = WsState.Reconnecting
|
||||
true
|
||||
}
|
||||
|
||||
AuthRecovery.Transient -> {
|
||||
_state.value = WsState.Reconnecting
|
||||
delay(backoffDelayMs(attempt))
|
||||
attempt += 1
|
||||
true
|
||||
}
|
||||
|
||||
AuthRecovery.Unrecoverable -> endWithAuthFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun endWithAuthFailure(): Boolean {
|
||||
wantConnection = false
|
||||
_state.value = WsState.Offline
|
||||
onAuthFailed()
|
||||
return false
|
||||
}
|
||||
|
||||
/** 建立一次连接并挂起,直到它关闭 / 失败 / 被取消。 */
|
||||
private suspend fun runSession(): SessionEnd {
|
||||
val url = buildUrl() ?: return SessionEnd.ConfigMissing
|
||||
_state.value = if (attempt == 0) WsState.Connecting else WsState.Reconnecting
|
||||
val finished = CompletableDeferred<SessionEnd>()
|
||||
val socket = wsClient.newWebSocket(
|
||||
Request.Builder().url(url).build(),
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
attempt = 0
|
||||
_state.value = WsState.Connected
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
// 未知 type / 坏帧由 parse 返回 null,静默丢弃。
|
||||
WsEvent.parse(text)?.let { _events.tryEmit(it) }
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
finished.complete(SessionEnd.Closed)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
val end = if (isAuthFailure(response, t)) SessionEnd.AuthFailed else SessionEnd.Closed
|
||||
finished.complete(end)
|
||||
}
|
||||
},
|
||||
)
|
||||
webSocket = socket
|
||||
return try {
|
||||
finished.await()
|
||||
} finally {
|
||||
if (webSocket === socket) webSocket = null
|
||||
socket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 握手鉴权失败是**握手前**的 HTTP 401(不是 WS close),OkHttp 会把它随 [onFailure] 的
|
||||
* `response` 一起回调;个别路径拿不到 response 时再退化为消息文本匹配。
|
||||
*/
|
||||
private fun isAuthFailure(response: Response?, t: Throwable): Boolean {
|
||||
if (response?.code == 401) return true
|
||||
return t.message?.contains("401") == true
|
||||
}
|
||||
|
||||
private fun buildUrl(): HttpUrl? {
|
||||
val token = tokenProvider()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val base = baseUrlProvider()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val httpUrl = ApiClientFactory.normalizeBaseUrl(base).toHttpUrlOrNull() ?: return null
|
||||
return httpUrl.newBuilder()
|
||||
.addPathSegments("ws/chat")
|
||||
.addQueryParameter("token", token)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** 1s/2s/4s/8s/16s/30s,上限 30s;加 0~25% 抖动避免同时重连。 */
|
||||
private fun backoffDelayMs(attempt: Int): Long {
|
||||
val shift = attempt.coerceIn(0, MAX_BACKOFF_SHIFT)
|
||||
val base = (INITIAL_BACKOFF_MS shl shift).coerceAtMost(MAX_BACKOFF_MS)
|
||||
val jitter = Random.nextLong(base / JITTER_DIVISOR + 1)
|
||||
return (base + jitter).coerceAtMost(MAX_BACKOFF_MS)
|
||||
}
|
||||
|
||||
private fun ensureScope(): CoroutineScope =
|
||||
scope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
|
||||
|
||||
private enum class SessionEnd { Closed, AuthFailed, ConfigMissing }
|
||||
|
||||
private companion object {
|
||||
const val PING_INTERVAL_SECONDS = 30L
|
||||
const val CONNECT_TIMEOUT_SECONDS = 10L
|
||||
const val INITIAL_BACKOFF_MS = 1_000L
|
||||
const val MAX_BACKOFF_MS = 30_000L
|
||||
const val MAX_BACKOFF_SHIFT = 5
|
||||
const val JITTER_DIVISOR = 4L
|
||||
const val EVENT_BUFFER = 512
|
||||
const val CLOSE_CODE_NORMAL = 1000
|
||||
const val CLOSE_REASON_CLIENT = "client closed"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,163 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 上行事件(客户端 → 服务端)。
|
||||
*
|
||||
* 帧壳:`{type, reqId?, data?}`(api-reference §6.2)。字段名逐字对齐 §6.3:
|
||||
* `chat` / `interrupt` / `voice_input` / `mode_switch` / `subagent_ask` / `task_output_query`。
|
||||
*
|
||||
* 序列化用 [buildJsonObject] 显式拼装,而不是 `@Serializable` 数据类,原因:
|
||||
* 上行帧是「信封 + 变长 data」,这样能保证 `type` 与 `data` 的嵌套结构不随类字段变化,
|
||||
* 且可选字段为 `null` 时整键省略(与后端 `omitempty` 语义一致)。
|
||||
*/
|
||||
sealed interface WsOutbound {
|
||||
|
||||
/** 事件类型,即帧壳的 `type`。 */
|
||||
val type: String
|
||||
|
||||
/** 请求关联 id;服务端当前不回填(api-reference §6.3),预留。 */
|
||||
val reqId: String? get() = null
|
||||
|
||||
/** `data` 负载;返回 `null` 时省略该键。 */
|
||||
fun data(): JsonElement?
|
||||
|
||||
/** 编码成一条文本帧。 */
|
||||
fun toJson(): String = buildJsonObject {
|
||||
put("type", type)
|
||||
reqId?.let { put("reqId", it) }
|
||||
data()?.let { put("data", it) }
|
||||
}.toString()
|
||||
|
||||
/** `chat`:`{sessionId?, text, image?, deviceId?, clientMsgId?, parallel?}`;text+image 至少一项。 */
|
||||
data class Chat(
|
||||
val text: String,
|
||||
val sessionId: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val image: String? = null,
|
||||
/**
|
||||
* 客户端幂等键(契约 v0.56 §3.13):同一会话内同一个非空值只承认第一条,
|
||||
* 重复到达服务端回下行帧 `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"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject {
|
||||
sessionId?.let { put("sessionId", it) }
|
||||
put("text", text)
|
||||
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?, turnId?}`。命中空闲会话为 no-op。 */
|
||||
data class Interrupt(
|
||||
val sessionId: String,
|
||||
/**
|
||||
* 打断范围(契约 v0.56/A2,api-client §3.1):取值域 `{"", "all", "current"}`。
|
||||
* `null` = **整键省略**——服务端视同 `"all"`(取消在跑回合 + 清空待处理队列),
|
||||
* 也就是加入该字段之前那条帧**逐字节相同**;`"current"` = 只取消在跑回合、**保留**待处理队列。
|
||||
* 其它取值服务端定向回 `error{bad_request}`(不打断任何会话)——故本层**原样透传**,
|
||||
* 不做取值校验、不静默回退成默认(服务端的开放取值域由服务端裁决)。
|
||||
*/
|
||||
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"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject {
|
||||
put("sessionId", sessionId)
|
||||
scope?.let { put("scope", it) }
|
||||
turnId?.let { put("turnId", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
|
||||
data class VoiceInput(
|
||||
val audio: String? = null,
|
||||
val format: String? = null,
|
||||
val text: String? = null,
|
||||
val sessionId: String? = null,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "voice_input"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject {
|
||||
audio?.let { put("audio", it) }
|
||||
format?.let { put("format", it) }
|
||||
text?.let { put("text", it) }
|
||||
sessionId?.let { put("sessionId", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `mode_switch`:服务端原样广播回显,负载形状不限,这里发 `{mode}`。 */
|
||||
data class ModeSwitch(
|
||||
val mode: String,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "mode_switch"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject { put("mode", mode) }
|
||||
}
|
||||
|
||||
/** `subagent_ask`:`{id, mode?, n?, query?}`。`mode` ∈ `""/full/tail/keyword`。 */
|
||||
data class SubagentAsk(
|
||||
val id: String,
|
||||
val mode: String? = null,
|
||||
val n: Int? = null,
|
||||
val query: String? = null,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "subagent_ask"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject {
|
||||
put("id", id)
|
||||
mode?.let { put("mode", it) }
|
||||
n?.let { put("n", it) }
|
||||
query?.let { put("query", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `task_output_query`:`{taskId}`。 */
|
||||
data class TaskOutputQuery(
|
||||
val taskId: String,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "task_output_query"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject { put("taskId", taskId) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
/**
|
||||
* 实时通道连接状态。
|
||||
*
|
||||
* 迁移:`Idle → Connecting → Connected → Reconnecting(退避) → Offline`。
|
||||
* `Offline` 只在**不可恢复**的情况下出现(握手 401 / 缺少 token 或服务器地址),
|
||||
* 需要上层重新登录或配置服务器后再调 [ChatSocket.connect]。
|
||||
*/
|
||||
enum class WsState {
|
||||
/** 未启动:初始态,或主动 [ChatSocket.close] 之后。 */
|
||||
Idle,
|
||||
|
||||
/** 首次握手进行中。 */
|
||||
Connecting,
|
||||
|
||||
/** 已连接,可收发帧。 */
|
||||
Connected,
|
||||
|
||||
/** 连接断开,正在按退避策略等待重连。 */
|
||||
Reconnecting,
|
||||
|
||||
/**
|
||||
* 不可恢复(握手 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,
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
/**
|
||||
* 管理侧权限矩阵(api-client §2.0)的**客户端镜像**。
|
||||
*
|
||||
* 后端契约明确要求客户端自行降级(`Docs/api-client.md`:「客户端需按本矩阵自行降级」):
|
||||
* 不该读的页**不发请求**,而不是发出去等 403。这里的判断是纯函数,便于单测。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 管理页可见性分档,按权限递增。
|
||||
*
|
||||
* - [Monitor]:api-client §2.0 的①档,`owner`/`admin`/`viewer` 均可读(只读非敏感投影)。
|
||||
* - [OwnerAdmin]:②档,仅 `owner`/`admin`(含他人数据 / 隐私正文),`viewer` 一律 403。
|
||||
*/
|
||||
enum class AdminTier(val rank: Int) {
|
||||
Monitor(0),
|
||||
OwnerAdmin(1),
|
||||
}
|
||||
|
||||
/** 已接线角色,`rank` 与后端 `owner>admin>viewer` 递增一致。 */
|
||||
enum class AdminRole(val rank: Int) {
|
||||
Viewer(0),
|
||||
Admin(1),
|
||||
Owner(2),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 角色名解析。大小写 / 首尾空白不敏感;**未知或空值按 [Viewer] 处理**——
|
||||
* 与服务端一样 fail-closed:宁可少给可见面,也不去撞预期中的 403。
|
||||
*/
|
||||
fun of(raw: String?): AdminRole = when (raw?.trim()?.lowercase()) {
|
||||
"owner" -> Owner
|
||||
"admin" -> Admin
|
||||
else -> Viewer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理页清单:只声明「有哪些页 + 需要哪一档权限」。
|
||||
* 入口页、ViewModel 与各页标题都按本清单驱动,新增管理模块只需在此追加一项。
|
||||
*/
|
||||
enum class AdminPage(
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val required: AdminTier,
|
||||
) {
|
||||
Overview("概览", "请求指标与最近 trace", AdminTier.Monitor),
|
||||
Personas("人格", "角色目录与思考旋钮", AdminTier.Monitor),
|
||||
Memories("记忆", "记忆正文(scope / 内容)", AdminTier.OwnerAdmin),
|
||||
Backthink("后台思考", "后台思考与主动推送记录", AdminTier.OwnerAdmin),
|
||||
}
|
||||
|
||||
/** 当前角色能否使用该页;不能则 UI 禁用入口、ViewModel 直接给「无权限」。 */
|
||||
fun canAccess(role: AdminRole, page: AdminPage): Boolean = role.rank >= page.required.rank
|
||||
|
||||
/**
|
||||
* 能否接收可观测实时推送帧(`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)
|
||||
@@ -0,0 +1,26 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
|
||||
/**
|
||||
* 管理页取数失败。**「无权限」必须与其它失败分开**:UI 要能给出「无权限」状态,
|
||||
* 而不是把 403 当成空列表渲染成「暂无数据」。
|
||||
*/
|
||||
sealed interface AdminFailure {
|
||||
/** HTTP 403(角色不够)或 401(会话失效)。 */
|
||||
data object Forbidden : AdminFailure
|
||||
|
||||
data class Other(val message: String) : AdminFailure
|
||||
}
|
||||
|
||||
/** 把仓库层的 `Result` 失败收敛成 [AdminFailure];成功时返回 null。 */
|
||||
fun Throwable.toAdminFailure(): AdminFailure {
|
||||
val error = (this as? CyreneException)?.error
|
||||
return when {
|
||||
error is CyreneError.Unauthorized -> AdminFailure.Forbidden
|
||||
error is CyreneError.Server && error.httpStatus == 403 -> AdminFailure.Forbidden
|
||||
error != null -> AdminFailure.Other(error.userMessage)
|
||||
else -> AdminFailure.Other("操作失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import top.yeij.cyrene.domain.enums.EnumCatalog
|
||||
import top.yeij.cyrene.domain.parse.ContentPart
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
private val LocalTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
/**
|
||||
* RFC3339(trace 的 `startedAt` / `endedAt`、trace event 的 `time`)→ 本地时间文本。
|
||||
* 秒精度串与纳秒精度串都要吃得下;解析不了就**原样返回**,绝不静默清空。
|
||||
*/
|
||||
fun formatRfc3339(raw: String?): String? {
|
||||
val text = raw?.takeIf { it.isNotBlank() } ?: return null
|
||||
return runCatching {
|
||||
OffsetDateTime.parse(text).atZoneSameInstant(ZoneId.systemDefault()).format(LocalTime)
|
||||
}.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 的两种时间形状)。
|
||||
*/
|
||||
fun formatEpochMillis(millis: Long): String =
|
||||
Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).format(LocalTime)
|
||||
|
||||
/** 纳秒时长 → 按量级挑单位。 */
|
||||
fun formatNanos(nanos: Long): String = when {
|
||||
nanos < 1_000 -> "$nanos ns"
|
||||
nanos < 1_000_000 -> String.format(Locale.ROOT, "%.1f µs", nanos / 1_000.0)
|
||||
nanos < 1_000_000_000 -> String.format(Locale.ROOT, "%.1f ms", nanos / 1_000_000.0)
|
||||
else -> String.format(Locale.ROOT, "%.2f s", nanos / 1_000_000_000.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计数表(`perMethod` / `perPath` / `statusCounts`)→ 一行文本,按次数倒序。
|
||||
* 键名一律原样保留(开放集合,不做白名单过滤,也不截断条目)。
|
||||
*/
|
||||
fun formatCounts(counts: Map<String, Long>): String =
|
||||
counts.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<String, Long>> { it.value }.thenBy { it.key })
|
||||
.joinToString(" · ") { (key, value) -> "$key $value" }
|
||||
|
||||
/** 后端用 float64 表达计数 / 毫秒,整数时抹掉无意义的小数位。 */
|
||||
fun formatDouble(value: Double, decimals: Int = 2): String =
|
||||
BigDecimal(value).setScale(decimals, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
|
||||
|
||||
/** 任意 `props` 对象 → 紧凑 JSON 文本,原样展示(不裁剪、不丢键)。 */
|
||||
fun formatProps(props: Map<String, JsonElement>?): String? =
|
||||
props?.takeIf { it.isNotEmpty() }?.let { JsonObject(it).toString() }
|
||||
|
||||
private val traceKindLabels = mapOf(
|
||||
"llm_call" to "LLM 调用",
|
||||
"tool_call" to "工具调用",
|
||||
"orchestration" to "编排",
|
||||
"state_transition" to "状态迁移",
|
||||
"subagent" to "子代理",
|
||||
"memory" to "记忆",
|
||||
"guardrail" to "护栏",
|
||||
)
|
||||
|
||||
private val traceStatusLabels = mapOf(
|
||||
"ok" to "成功",
|
||||
"error" to "失败",
|
||||
)
|
||||
|
||||
private val memoryScopeLabels = mapOf(
|
||||
"user" to "用户",
|
||||
"persona" to "人格",
|
||||
"project" to "项目",
|
||||
)
|
||||
|
||||
/**
|
||||
* 未知取值回落约定(api-client §1 红线):不在表中的取值**原样返回**——
|
||||
* 不丢弃、不报错、不替换占位符。服务端新增取值属非破坏性变更。
|
||||
*/
|
||||
fun traceKindLabel(raw: String): String = traceKindLabels[raw] ?: raw
|
||||
|
||||
fun traceStatusLabel(raw: String): String = traceStatusLabels[raw] ?: raw
|
||||
|
||||
/**
|
||||
* `mode` / `mood` 的标签来自 `/meta/enums` 的权威表([catalog]),客户端不再硬编码——
|
||||
* 表里没有的取值同样原样返回([EnumCatalog] 负责该回落)。
|
||||
* `trace` / `memory` 那几类**不在**该端点的表内,各自保留本地映射(见上)。
|
||||
*/
|
||||
fun personaModeLabel(raw: String, catalog: EnumCatalog): String = catalog.modeLabel(raw)
|
||||
|
||||
fun memoryScopeLabel(raw: String): String = memoryScopeLabels[raw] ?: raw
|
||||
|
||||
fun moodLabel(raw: String, catalog: EnumCatalog): String = catalog.moodLabel(raw)
|
||||
|
||||
/** 三态展示:null(未配置)与 true/false 是**三种不同**状态,不能合并。 */
|
||||
fun triStateLabel(value: Boolean?): String = when (value) {
|
||||
null -> "未配置"
|
||||
true -> "显示"
|
||||
false -> "不显示"
|
||||
}
|
||||
|
||||
/** 空白字段的展示兜底:只处理「没有值」,不参与未知取值的回落。 */
|
||||
fun orDash(raw: String?): String = raw?.takeIf { it.isNotBlank() } ?: "-"
|
||||
|
||||
/**
|
||||
* 后台思考 `conclusion` 的一段:段内按渲染顺序排列的片段(正文 / 动作 / 旁白)。
|
||||
* 段与段之间的间距由 UI 决定;段内片段顺序即原文顺序。
|
||||
*/
|
||||
data class ConclusionParagraph(val parts: List<ContentPart>)
|
||||
|
||||
/**
|
||||
* `conclusion` → 有序渲染模型,供 UI 逐段渲染。
|
||||
*
|
||||
* 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `<p></p>`、
|
||||
* `<action>` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]:
|
||||
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。
|
||||
* 空段(`<p></p>` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。
|
||||
* 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。
|
||||
* `null` / 纯空白输入返回空列表。
|
||||
*/
|
||||
fun conclusionParagraphs(raw: String?): List<ConclusionParagraph> {
|
||||
val text = raw?.takeIf { it.isNotBlank() } ?: return emptyList()
|
||||
return parse(text).mapNotNull { bubble ->
|
||||
val parts = buildList {
|
||||
addAll(bubble.head)
|
||||
if (bubble.body.isNotEmpty()) add(ContentPart.Text(bubble.body))
|
||||
addAll(bubble.tail)
|
||||
}
|
||||
parts.takeIf { list -> list.any { it.text.isNotBlank() } }?.let(::ConclusionParagraph)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/** 等比缩放后的目标尺寸(px)。 */
|
||||
data class ScaledSize(val width: Int, val height: Int)
|
||||
|
||||
/**
|
||||
* 发送前图片的**体积预算**与尺寸规划(纯函数,编码器与测试共用;不含任何 Android 依赖)。
|
||||
*
|
||||
* ## 为什么必须缩放 / 重编码
|
||||
* 服务端把 `image` 字符串**原样落库**(api-client §1 行 3 + 协同文档 G3),于是
|
||||
* `GET /sessions/{id}/messages` 的**每一页**、WS 握手 `history` 的**每一次回放**
|
||||
* 都要把整段 base64 再传一遍。所以「发一张 8 MB 的原图」= 以后每次翻历史都重传 11 MB。
|
||||
* 客户端预算是这个成本的唯一闸门,**不是优化**。
|
||||
*
|
||||
* 两层上限:
|
||||
* - [MAX_DECODED_BYTES] = 1 MiB:**本端的**目标(缩放 + 重编码后必须 ≤ 它)——
|
||||
* 服务端 5 MiB 只管「收不收」,管不了「历史有多重」;
|
||||
* - [SERVER_MAX_DECODED_BYTES] = 5 MiB:服务端硬顶(解码后),超限 `400 image_too_large`。
|
||||
* 它只作为**最后一道兜底检查**:真要走上传(`POST /api/v1/upload`)时再放宽。
|
||||
*
|
||||
* 校验按**字符串长度**([SERVER_MAX_CHARS])而不是解码字节数——与服务端入口层同一口径
|
||||
* (`chatImageCharsFor`),这样两端不可能漂移。
|
||||
*/
|
||||
object ChatImageBudget {
|
||||
|
||||
/** 服务端 `image` 解码后上限:5 MiB(与 `POST /api/v1/upload` 同口径)。 */
|
||||
const val SERVER_MAX_DECODED_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/** `data:` URI 前缀(`data:image/jpeg;base64,`)的字符余量,与服务端同一取值。 */
|
||||
const val PREFIX_ALLOWANCE_CHARS = 64
|
||||
|
||||
/** [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
|
||||
|
||||
/**
|
||||
* 最长边的目标像素。1280 与 provider 自己入模型前的缩放口径(约 1300×1300)同量级,
|
||||
* 所以再放大不会有任何可见收益——省的是库与历史的字节,不是 token。
|
||||
*/
|
||||
const val MAX_DIMENSION_PX = 1280
|
||||
|
||||
/** 字符串长度是否在服务端上限内(入口层同一判据:`<=` 才算通过)。 */
|
||||
fun withinServerLimit(dataUriChars: Int): Boolean = dataUriChars <= SERVER_MAX_CHARS
|
||||
|
||||
/** 编码后字节数是否已落到本端目标内。 */
|
||||
fun withinTarget(byteCount: Int): Boolean = byteCount <= MAX_DECODED_BYTES
|
||||
|
||||
/**
|
||||
* 等比缩放到最长边不超过 [maxDimension];**只缩不放**(小图保持原尺寸),
|
||||
* 边长至少 1 px(极端细长图缩放后可能算成 0)。
|
||||
*/
|
||||
fun scaledSize(width: Int, height: Int, maxDimension: Int = MAX_DIMENSION_PX): ScaledSize {
|
||||
val w = width.coerceAtLeast(1)
|
||||
val h = height.coerceAtLeast(1)
|
||||
val longest = maxOf(w, h)
|
||||
if (longest <= maxDimension) return ScaledSize(w, h)
|
||||
val ratio = maxDimension.toDouble() / longest
|
||||
return ScaledSize(
|
||||
width = (w * ratio).toInt().coerceAtLeast(1),
|
||||
height = (h * ratio).toInt().coerceAtLeast(1),
|
||||
)
|
||||
}
|
||||
|
||||
/** 拼 `data:` URI([mime] 形如 `image/jpeg`,[base64] 为单行 base64)。 */
|
||||
fun dataUri(mime: String, base64: String): String = "data:$mime;base64,$base64"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/** 一张**已重编码**的图片该怎么送上去。 */
|
||||
enum class ImageDelivery {
|
||||
/** 直接把 `data:` URI 塞进 `chat.image`:一次请求,但字节永久留在历史里。 */
|
||||
Inline,
|
||||
|
||||
/** 先 `POST /api/v1/upload` 换 URL,再把 URL 当 `chat.image`:多一次往返,历史里只留几十字节。 */
|
||||
Upload,
|
||||
}
|
||||
|
||||
/**
|
||||
* 内联(base64)与上传(URL)的分界(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 这是一笔什么账
|
||||
* 服务端把 `chat.image` 字符串**原样落库**,所以两边的成本不是「发一次」而是「发多少次」:
|
||||
*
|
||||
* | | 发出时 | 之后每一次历史取数 |
|
||||
* |---|---|---|
|
||||
* | 内联 | 请求体里带 base64(4/3 × B 字符) | **再带一遍**(REST 每页、WS `history` 每次回放、每次上拉) |
|
||||
* | 上传 | 多**一次** HTTP 往返;字节同样只传一遍 | 只剩那个 URL(几十字节) |
|
||||
*
|
||||
* 也就是说:上传不是「省一次流量」,是**用一次往返买断此后所有的历史重量**。
|
||||
*
|
||||
* ## 阈值:64 KiB([INLINE_MAX_BYTES],含等于)
|
||||
* 重编码后的字节数 `B`,内联的代价是每次历史取数再多 `4/3 × B`:
|
||||
* - `B = 64 KiB` → 每次取数 +85 KB。一个会话里 20 张这种图,每次冷启动 / 重连回放就要多传
|
||||
* ≥1.7 MB(1 Mbps 下约 14 秒纯图片流量),而那次上传往返在局域网是毫秒、在移动网也就是
|
||||
* 一两百毫秒——**从第二次取数起就稳赚**。
|
||||
* - `B ≈ 32 KiB`(缩略图、小截图)→ 每次取数 +43 KB,省下的量与一次往返本身的量级相当,
|
||||
* 而多出来的一次请求**必然**多一个失败面(上传失败就得让用户重发,内联不会)。
|
||||
* 这种量级下「一次请求发完」更划算,所以留在内联。
|
||||
*
|
||||
* 阈值取整到 64 KiB 而不是更细的数,是因为它本来就是个成本量级的判断,不是服务端限制:
|
||||
* 服务端的两个 5 MiB 上限(`chat.image` 与 `/upload`)离这里都还差两个数量级,
|
||||
* **本决策与「装不装得下」无关**(重编码出口已保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES])。
|
||||
*/
|
||||
object ChatImageDelivery {
|
||||
|
||||
/**
|
||||
* 内联与上传的分界:**重编码后**的字节数(含等于 → 内联)。
|
||||
*
|
||||
* 必须严格小于 [ChatImageBudget.MAX_DECODED_BYTES],否则上传分支永远走不到(死代码);
|
||||
* 这一条由 `ChatImageDeliveryTest` 钉住。
|
||||
*/
|
||||
const val INLINE_MAX_BYTES = 64 * 1024
|
||||
|
||||
/** @param preparedBytes [top.yeij.cyrene.data.image.ChatImagePreparer] 产出的字节数。 */
|
||||
fun forPreparedBytes(preparedBytes: Int): ImageDelivery =
|
||||
if (preparedBytes <= INLINE_MAX_BYTES) ImageDelivery.Inline else ImageDelivery.Upload
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/** 一条消息该走哪条上行通道。 */
|
||||
enum class SendChannel {
|
||||
/** 短文本走 WS `chat` 帧:直达、绕过有损总线,且今天就能用。 */
|
||||
Ws,
|
||||
|
||||
/** 长文本 / 任何图片走 REST `POST /api/v1/chat`(`stream:true`)。 */
|
||||
Rest,
|
||||
}
|
||||
|
||||
/**
|
||||
* 上行路由规则(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么必须有这条规则
|
||||
* WS 帧的读上限是**服务端** `wsReadLimit = 4096` 字节/帧,且**超限由传输层直接断连**——
|
||||
* 不是回一个错误,而是把这条连接踢掉(api-client §1 行 3 G3 的诚实边界)。所以
|
||||
* 「长文发不出去」在今天不是「服务端拒绝」,是**发送帧把连接打断**。REST 没有这个上限,
|
||||
* 且 `stream:true` 的回合同样在**已连接的 WS** 上流式回来 ⇒ 「REST 发、WS 收」不牺牲流式体验。
|
||||
*
|
||||
* 图片一律走 REST:真实图片的 base64 远超 4096 字节,在 WS 上**必然**断连。
|
||||
* 文档附件同理一律走 REST(上传换引用这件事整个压在同一条通道上,见 [DocumentDelivery])。
|
||||
*
|
||||
* ## 阈值
|
||||
* 判据是**文本的 UTF-8 字节数**(不是字符数——中文一个字 3 字节,按字符数算会在
|
||||
* 333 个汉字时就撞上 4096)。上限取 [WS_TEXT_LIMIT_BYTES] = 4096 − 512 字节预留:
|
||||
* 预留覆盖 `{"type","data":{"sessionId","text","deviceId","clientMsgId","image"}}` 的
|
||||
* JSON 外壳(键名 + 值,UTF-8 最坏情况约 150 字节),并留出余量。留得保守是因为
|
||||
* **超限的代价是断连**,而多走一次 REST 的代价只是几十毫秒——风险不对称,宁可早退。
|
||||
*
|
||||
* 边界:`utf8Length(text) <= WS_TEXT_LIMIT_BYTES` → WS;多 1 字节即 REST。
|
||||
*/
|
||||
object ChatSendRouting {
|
||||
|
||||
/** 服务端 WS 读上限(`wsReadLimit`,契约 v0.56 明确记载)。 */
|
||||
const val WS_FRAME_LIMIT_BYTES = 4096
|
||||
|
||||
/** WS `chat` 帧 JSON 外壳的预留:键名 + `sessionId` / `deviceId` / `clientMsgId` 等值的字节数。 */
|
||||
const val WS_ENVELOPE_RESERVE_BYTES = 512
|
||||
|
||||
/** 允许走 WS 的**文本**字节上限(不含图片:有图片一律走 REST)。 */
|
||||
const val WS_TEXT_LIMIT_BYTES = WS_FRAME_LIMIT_BYTES - WS_ENVELOPE_RESERVE_BYTES
|
||||
|
||||
/** 文本的 UTF-8 字节数;`text` 为空返回 0。 */
|
||||
fun utf8Length(text: String): Int = text.toByteArray(Charsets.UTF_8).size
|
||||
|
||||
/**
|
||||
* 这一条该走哪条通道。
|
||||
* @param hasImage 本次是否带图片(带图**永远**走 REST)
|
||||
* @param hasDocuments 本次是否带**文档附件**(带附件**永远**走 REST):文档本体要先经
|
||||
* `POST /api/v1/documents` 上传,引用只随 `POST /api/v1/chat` 发出——
|
||||
* 「文档一律走 REST」见 [DocumentDelivery] 第 2 条。默认 false ⇒ 既有调用与旧行为逐字不变。
|
||||
*/
|
||||
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,97 @@
|
||||
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>` 在这里也是普通文本。
|
||||
*/
|
||||
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,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,63 @@
|
||||
package top.yeij.cyrene.domain.enums
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import top.yeij.cyrene.data.api.dto.EnumItemDto
|
||||
import top.yeij.cyrene.data.api.dto.EnumTableDto
|
||||
|
||||
/**
|
||||
* 枚举标签查找器(api-client §1.1):`/meta/enums` 抓到什么就查什么。
|
||||
*
|
||||
* **未知取值回落(红线)**:不在表内的取值一律**原样返回**——不丢弃、不报错、不替换占位符、
|
||||
* 不猜测;服务端新增取值属非破坏性变更,不得因此清空角标或中断渲染。
|
||||
*
|
||||
* 因此这里**没有任何硬编码兜底表**:宁可显示原始 id,也不能让客户端自建的表与服务端漂移。
|
||||
* 还没拿到表(未登录 / 首次拉取失败且无快照)时用 [Empty],一切原样显示。
|
||||
*
|
||||
* 判定只比对 `id`(大小写敏感,契约 id 为小写);`label` 仅作显示默认值。
|
||||
*/
|
||||
class EnumCatalog private constructor(
|
||||
private val platforms: Map<String, String>,
|
||||
private val moods: Map<String, String>,
|
||||
private val modes: Map<String, String>,
|
||||
) {
|
||||
|
||||
/** 表内 → 默认标签;不在表内 → 原始值。 */
|
||||
fun platformLabel(id: String): String = platforms[id] ?: id
|
||||
|
||||
fun moodLabel(id: String): String = moods[id] ?: id
|
||||
|
||||
fun modeLabel(id: String): String = modes[id] ?: id
|
||||
|
||||
/**
|
||||
* 表内 → 默认标签;不在表内 → `null`。
|
||||
* 给「认识」与「不认识」需要走**不同**回落的调用方用(如 `deviceId` 前缀:
|
||||
* 认识的平台名取友好标签,不认识的原样显示整串 `deviceId`,而不是只留前缀)。
|
||||
*/
|
||||
fun platformLabelOrNull(id: String): String? = platforms[id]
|
||||
|
||||
companion object {
|
||||
/** 尚无权威表:一切原样显示。 */
|
||||
val Empty = EnumCatalog(emptyMap(), emptyMap(), emptyMap())
|
||||
|
||||
fun of(table: EnumTableDto?): EnumCatalog = if (table == null) {
|
||||
Empty
|
||||
} else {
|
||||
EnumCatalog(
|
||||
platforms = table.platforms.toLabels(),
|
||||
moods = table.moods.toLabels(),
|
||||
modes = table.modes.toLabels(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 空 id 不入表:否则查空串会拿到别人的标签,等于凭空造出一个标签。
|
||||
* 空 label 同样不入表:查得到却没有标签只会把角标显示成空白,不如原样显示 id。
|
||||
*/
|
||||
private fun List<EnumItemDto>.toLabels(): Map<String, String> = mapNotNull { item ->
|
||||
item.takeIf { it.id.isNotBlank() && it.label.isNotBlank() }?.let { it.id to it.label }
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局注入枚举标签查找器,供聊天角标 / 管理页等深层组件读取(与 `LocalAppPrefs` 同套路)。 */
|
||||
val LocalEnumCatalog = staticCompositionLocalOf { EnumCatalog.Empty }
|
||||
@@ -0,0 +1,409 @@
|
||||
package top.yeij.cyrene.domain.parse
|
||||
|
||||
import kotlin.math.min
|
||||
|
||||
/*
|
||||
* ChatContentParser —— 纯 Kotlin 的聊天内容解析器(无 Android / Compose 依赖)。
|
||||
*
|
||||
* 权威规则:
|
||||
* - `configs/personas/cyrene.yaml`「分段约定」「表达方式:动作与旁白」;
|
||||
* - `devdocs/03-客户端细化开发方案.md` §5 渲染管线;
|
||||
* - Web 端 `frontend/web/src/lib/parseContent.ts` + `components/chat.tsx`(行为对齐基准)。
|
||||
*
|
||||
* 语义摘要:
|
||||
* - `<p>…</p>`:每个完整块 = 一条独立气泡;无完整 `<p>` 时回退按空行 `\n\s*\n` 切分;
|
||||
* 单个 `\n` 是句内换行,绝不切分。未闭合的 `<p>` 不构成块,按普通文本处理(流式下会先缓存)。
|
||||
* `<p>` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
|
||||
* - `<action>…</action>` / `<narration>…</narration>`:旁白/动作,不独立成气泡,依附气泡:
|
||||
* 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。
|
||||
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
|
||||
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
|
||||
* 关键词 旁白/内心 → narration,其余 → action。
|
||||
* - 未闭合/畸形的标签一律回落为普通文本,绝不抛异常。
|
||||
*
|
||||
* `ParsedBubble` 结构(每条气泡渲染一个):
|
||||
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
|
||||
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
|
||||
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
|
||||
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
|
||||
* 无正文时 `body` 为空串,仅 `head` 承载整条消息(纯动作/旁白气泡)。
|
||||
* `parse("")` / 纯空白输入返回空列表;纯空白且无旁白的气泡会被丢弃。
|
||||
*
|
||||
* 流式边界决策(`StreamingContentParser`):
|
||||
* - `onDelta` 返回「当前已确定前缀」的解析结果;`finish` 返回全量缓冲的解析结果,
|
||||
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
|
||||
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
|
||||
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
|
||||
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
|
||||
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
|
||||
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
|
||||
* - 标签残缺判断只影响「何时产出」,不影响最终结果;扣留期间不会抛异常。
|
||||
* - 本类无副作用、非线程安全,调用方保证单线程喂入即可。
|
||||
*/
|
||||
|
||||
/** 气泡内的一个有序片段。 */
|
||||
sealed interface ContentPart {
|
||||
val text: String
|
||||
|
||||
data class Text(override val text: String) : ContentPart
|
||||
|
||||
data class Action(override val text: String) : ContentPart
|
||||
|
||||
data class Narration(override val text: String) : ContentPart
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条可渲染的气泡:正文 + 有序片段 + 上/下依附的旁白块。
|
||||
* 字段含义见文件头注释;`body` 为空且 `head` 非空时,表示只有旁白没有正文。
|
||||
*/
|
||||
data class ParsedBubble(
|
||||
val body: String,
|
||||
val parts: List<ContentPart>,
|
||||
val head: List<ContentPart>,
|
||||
val tail: List<ContentPart>,
|
||||
) {
|
||||
val isEmpty: Boolean
|
||||
get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
|
||||
}
|
||||
|
||||
/** 完整 `<p>…</p>` 块(允许 `class` 等属性,但要求标签名后为空白或直接 `>`)。 */
|
||||
private val P_BLOCK_RE = Regex("<p(\\s[^>]*)?>([\\s\\S]*?)</p>")
|
||||
|
||||
/**
|
||||
* 一次匹配旁白的三种等价写法;左侧优先(最靠左的匹配获胜),
|
||||
* 三种 body 均非贪婪,可跨换行;未闭合/畸形则整段回落为文本。
|
||||
* 捕获组:1 = 标签名,2 = 标签 body,3 = 全角星 body,4 = 半角星 body。
|
||||
*/
|
||||
private val ROLE_RE = Regex(
|
||||
"<\\s*(action|narration)\\s*>([\\s\\S]*?)</\\s*\\1\\s*>" +
|
||||
"|*([\\s\\S]*?)*" +
|
||||
"|\\*((?:动作|旁白|内心|神态|轻声)\\s*[::]?[\\s\\S]*?)\\*",
|
||||
)
|
||||
|
||||
/** 星号块开头的关键词(可带中/英文冒号)。 */
|
||||
private val ROLE_KEYWORD_RE = Regex("^(动作|旁白|内心|神态|轻声)\\s*[::]?\\s*")
|
||||
|
||||
private val ROLE_KEYWORDS = listOf("动作", "旁白", "内心", "神态", "轻声")
|
||||
|
||||
/** 空行切分:`\n` + 若干空白 + `\n`。 */
|
||||
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")
|
||||
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>$")
|
||||
|
||||
/** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */
|
||||
fun parse(raw: String): List<ParsedBubble> {
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
return buildBubbles(tokenize(raw))
|
||||
}
|
||||
|
||||
/**
|
||||
* 带缓冲的流式增量解析器:`onDelta` 逐段喂入,`finish` 收口。
|
||||
* 无副作用;调用方保证单线程访问。
|
||||
*/
|
||||
class StreamingContentParser {
|
||||
private val buffer = StringBuilder()
|
||||
|
||||
/** 当前累计的原始文本(含尚未确定、被扣留的尾部)。 */
|
||||
val raw: String
|
||||
get() = buffer.toString()
|
||||
|
||||
/** 追加一段 delta,返回「当前已确定前缀」解析出的气泡列表。 */
|
||||
fun onDelta(delta: String): List<ParsedBubble> {
|
||||
if (delta.isNotEmpty()) buffer.append(delta)
|
||||
val current = buffer.toString()
|
||||
return parse(current.substring(0, stablePrefixEnd(current)))
|
||||
}
|
||||
|
||||
/** 收口:对全部缓冲做一次性解析,结果与 `parse(拼接的全部 delta)` 完全一致。 */
|
||||
fun finish(): List<ParsedBubble> = parse(buffer.toString())
|
||||
|
||||
/** 清空缓冲,可复用本实例处理下一条消息。 */
|
||||
fun reset() {
|
||||
buffer.setLength(0)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 内部实现
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private sealed interface Tok {
|
||||
data class Txt(val text: String) : Tok
|
||||
|
||||
data class Act(val text: String) : Tok
|
||||
|
||||
data class Nar(val text: String) : Tok
|
||||
|
||||
object Break : Tok
|
||||
}
|
||||
|
||||
/** 先切 `<p>` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */
|
||||
private fun tokenize(raw: String): List<Tok> {
|
||||
val pMatches = P_BLOCK_RE.findAll(raw).toList()
|
||||
val out = ArrayList<Tok>()
|
||||
if (pMatches.isEmpty()) {
|
||||
appendAnnotations(raw, out, 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)
|
||||
out.add(Tok.Break)
|
||||
val inner = m.groupValues[2]
|
||||
if (inner.isNotEmpty()) appendAnnotations(inner, out, splitBlankLines = false)
|
||||
out.add(Tok.Break)
|
||||
last = m.range.last + 1
|
||||
}
|
||||
val trail = raw.substring(last)
|
||||
if (trail.isNotEmpty()) appendAnnotations(trail, out, splitBlankLines = false)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
|
||||
private fun appendAnnotations(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
|
||||
var last = 0
|
||||
for (m in ROLE_RE.findAll(text)) {
|
||||
val before = text.substring(last, m.range.first)
|
||||
if (before.isNotEmpty()) appendText(before, out, splitBlankLines)
|
||||
when (val part = classify(m)) {
|
||||
null -> appendText(m.value, out, splitBlankLines)
|
||||
is ContentPart.Text -> appendText(part.text, out, splitBlankLines)
|
||||
is ContentPart.Action -> out.add(Tok.Act(part.text))
|
||||
is ContentPart.Narration -> out.add(Tok.Nar(part.text))
|
||||
}
|
||||
last = m.range.last + 1
|
||||
}
|
||||
val tail = text.substring(last)
|
||||
if (tail.isNotEmpty()) appendText(tail, out, splitBlankLines)
|
||||
}
|
||||
|
||||
/** 追加普通文本;无 `<p>` 时按空行插入气泡边界。 */
|
||||
private fun appendText(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
|
||||
if (!splitBlankLines) {
|
||||
out.add(Tok.Txt(text))
|
||||
return
|
||||
}
|
||||
var last = 0
|
||||
for (m in BLANK_LINE_RE.findAll(text)) {
|
||||
val piece = text.substring(last, m.range.first)
|
||||
if (piece.isNotEmpty()) out.add(Tok.Txt(piece))
|
||||
out.add(Tok.Break)
|
||||
last = m.range.last + 1
|
||||
}
|
||||
val tail = text.substring(last)
|
||||
if (tail.isNotEmpty()) out.add(Tok.Txt(tail))
|
||||
}
|
||||
|
||||
/** 把一次正则匹配归类;返回 null 表示「整段保持普通文本」。 */
|
||||
private fun classify(m: MatchResult): ContentPart? {
|
||||
if (m.groups[1] != null) {
|
||||
val name = m.groupValues[1]
|
||||
val body = m.groupValues[2]
|
||||
return if (name == "action") ContentPart.Action(body) else ContentPart.Narration(body)
|
||||
}
|
||||
if (m.groups[3] != null) return classifyFullStar(m.groupValues[3])
|
||||
if (m.groups[4] != null) return classifyHalfStar(m.groupValues[4])
|
||||
return null
|
||||
}
|
||||
|
||||
/** 全角 `*…*`:有关键词按关键词定类,否则短且单行才当 action。 */
|
||||
private fun classifyFullStar(body: String): ContentPart? {
|
||||
matchRoleKeyword(body)?.let { return it }
|
||||
val trimmed = body.trim()
|
||||
val short = trimmed.isNotEmpty() && trimmed.length <= FULL_STAR_MAX_LEN
|
||||
if (short && !trimmed.contains('\n') && !trimmed.contains('\r')) {
|
||||
return ContentPart.Action(trimmed)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 半角 `*…*`:正则已保证以关键词开头,这里只负责剥离关键词与定类。 */
|
||||
private fun classifyHalfStar(body: String): ContentPart =
|
||||
matchRoleKeyword(body) ?: ContentPart.Action(body.trim())
|
||||
|
||||
/** 匹配并剥离开头关键词;旁白/内心 → narration,其余 → action。 */
|
||||
private fun matchRoleKeyword(body: String): ContentPart? {
|
||||
val m = ROLE_KEYWORD_RE.find(body) ?: return null
|
||||
val rest = body.substring(m.value.length).trim()
|
||||
return if (m.groupValues[1] == "旁白" || m.groupValues[1] == "内心") {
|
||||
ContentPart.Narration(rest)
|
||||
} else {
|
||||
ContentPart.Action(rest)
|
||||
}
|
||||
}
|
||||
|
||||
/** 以 Break 为界分组,把「只有旁白没有正文」的组并入相邻气泡,再逐组构造气泡。 */
|
||||
private fun buildBubbles(tokens: List<Tok>): List<ParsedBubble> {
|
||||
val groups = ArrayList<MutableList<Tok>>()
|
||||
var current = ArrayList<Tok>()
|
||||
for (t in tokens) {
|
||||
if (t is Tok.Break) {
|
||||
if (current.isNotEmpty()) groups.add(current)
|
||||
current = ArrayList()
|
||||
} else {
|
||||
current.add(t)
|
||||
}
|
||||
}
|
||||
if (current.isNotEmpty()) groups.add(current)
|
||||
|
||||
val merged = ArrayList<MutableList<Tok>>()
|
||||
var carry = ArrayList<Tok>()
|
||||
for (group in groups) {
|
||||
val combined = ArrayList<Tok>(carry.size + group.size)
|
||||
combined.addAll(carry)
|
||||
combined.addAll(group)
|
||||
carry = ArrayList()
|
||||
if (hasBodyText(combined)) merged.add(combined) else carry = combined
|
||||
}
|
||||
if (carry.isNotEmpty()) {
|
||||
if (merged.isNotEmpty()) merged.last().addAll(carry) else merged.add(carry)
|
||||
}
|
||||
return merged.mapNotNull { toBubble(it) }
|
||||
}
|
||||
|
||||
private fun hasBodyText(tokens: List<Tok>): Boolean =
|
||||
tokens.any { it is Tok.Txt && it.text.isNotBlank() }
|
||||
|
||||
/** 构造气泡:合并相邻文本、计算 body,并按首/尾规则分配 head / tail。 */
|
||||
private fun toBubble(tokens: List<Tok>): ParsedBubble? {
|
||||
val parts = ArrayList<ContentPart>(tokens.size)
|
||||
for (t in tokens) {
|
||||
when (t) {
|
||||
is Tok.Txt -> {
|
||||
val prev = parts.lastOrNull()
|
||||
if (prev is ContentPart.Text) {
|
||||
parts[parts.size - 1] = ContentPart.Text(prev.text + t.text)
|
||||
} else {
|
||||
parts.add(ContentPart.Text(t.text))
|
||||
}
|
||||
}
|
||||
|
||||
is Tok.Act -> parts.add(ContentPart.Action(t.text))
|
||||
is Tok.Nar -> parts.add(ContentPart.Narration(t.text))
|
||||
Tok.Break -> Unit
|
||||
}
|
||||
}
|
||||
if (parts.isEmpty()) return null
|
||||
|
||||
val firstText = parts.indexOfFirst { it is ContentPart.Text }
|
||||
val lastText = parts.indexOfLast { it is ContentPart.Text }
|
||||
val head = ArrayList<ContentPart>()
|
||||
val tail = ArrayList<ContentPart>()
|
||||
val body = StringBuilder()
|
||||
parts.forEachIndexed { i, part ->
|
||||
if (part is ContentPart.Text) {
|
||||
body.append(part.text)
|
||||
} else if (firstText < 0 || i < firstText || (i > firstText && i < lastText)) {
|
||||
head.add(part)
|
||||
} else {
|
||||
tail.add(part)
|
||||
}
|
||||
}
|
||||
val bodyText = body.toString().trim()
|
||||
if (bodyText.isEmpty() && head.isEmpty() && tail.isEmpty()) return null
|
||||
return ParsedBubble(body = bodyText, parts = parts.toList(), head = head.toList(), tail = tail.toList())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 流式边界:计算「可安全产出」的前缀长度
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 返回当前缓冲中可安全解析的前缀长度;其后的未完成片段一律扣留。 */
|
||||
private fun stablePrefixEnd(buf: String): Int {
|
||||
var cut = buf.length
|
||||
val openTags = ArrayDeque<Pair<String, Int>>()
|
||||
var i = 0
|
||||
while (i < buf.length) {
|
||||
val lt = buf.indexOf('<', i)
|
||||
if (lt < 0) break
|
||||
val gt = buf.indexOf('>', lt)
|
||||
if (gt < 0) {
|
||||
if (isPotentialTagPrefix(buf, lt)) cut = min(cut, lt)
|
||||
break
|
||||
}
|
||||
val tag = buf.substring(lt, gt + 1)
|
||||
val openName = openTagName(tag)
|
||||
if (openName != null) {
|
||||
openTags.addLast(openName to lt)
|
||||
} else {
|
||||
val closeName = closeTagName(tag)
|
||||
if (closeName == null) {
|
||||
i = lt + 1
|
||||
continue
|
||||
}
|
||||
val idx = openTags.indexOfLast { it.first == closeName }
|
||||
if (idx >= 0) openTags.removeAt(idx)
|
||||
}
|
||||
i = gt + 1
|
||||
}
|
||||
if (openTags.isNotEmpty()) cut = min(cut, openTags.first().second)
|
||||
|
||||
val freeStart = buf.lastIndexOf('>') + 1
|
||||
cut = withholdDanglingFullStar(buf, freeStart, cut)
|
||||
cut = withholdDanglingHalfStar(buf, freeStart, cut)
|
||||
return cut.coerceIn(0, buf.length)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
|
||||
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++
|
||||
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) }
|
||||
}
|
||||
|
||||
/** 自由文本区内孤立的全角 `*`(计数为奇)从最后一个起扣留。 */
|
||||
private fun withholdDanglingFullStar(buf: String, from: Int, cut: Int): Int {
|
||||
var count = 0
|
||||
var last = -1
|
||||
for (i in from until buf.length) {
|
||||
if (buf[i] == '*') {
|
||||
count++
|
||||
last = i
|
||||
}
|
||||
}
|
||||
return if (count % 2 == 1 && last >= 0) min(cut, last) else cut
|
||||
}
|
||||
|
||||
/** 自由文本区内孤立且可能开启关键词旁白的半角 `*` 从最后一个起扣留。 */
|
||||
private fun withholdDanglingHalfStar(buf: String, from: Int, cut: Int): Int {
|
||||
var count = 0
|
||||
var last = -1
|
||||
for (i in from until buf.length) {
|
||||
if (buf[i] == '*') {
|
||||
count++
|
||||
last = i
|
||||
}
|
||||
}
|
||||
if (count % 2 == 0 || last < 0) return cut
|
||||
return if (couldStartRoleKeyword(buf.substring(last + 1))) min(cut, last) else cut
|
||||
}
|
||||
|
||||
private fun couldStartRoleKeyword(after: String): Boolean {
|
||||
if (after.isEmpty()) return true
|
||||
return ROLE_KEYWORDS.any { it.startsWith(after) || after.startsWith(it) }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package top.yeij.cyrene.domain.share
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* 待落地内容的持有者(与 Android 无关,可单测)。
|
||||
*
|
||||
* ## 为什么它必须活在 Compose 与 ViewModel 之外
|
||||
* 内容要跨过「未登录 → 登录」:分享进来时可能还在登录页,甚至 ChatScreen 都还没被组合出来。
|
||||
* 而消费方(Compose 的 `LaunchedEffect`、Activity、各会话自己的 ChatViewModel)都会随重组 /
|
||||
* 重建 / 切会话而消失——把待落地内容放在任何一个消费方里,都会在它消失时一起丢掉。
|
||||
*
|
||||
* ## 唯一的不变量:一条内容只能被消费一次
|
||||
* [take] 是**原子出队**:两个消费者同时来(切会话、连接恢复各触发一次),各自只会拿到不同的一条。
|
||||
* 判据不能是「消费方自己记得已经发过」——消费方本身就会被重建,一重建就忘。
|
||||
*
|
||||
* ## 队列而不是单槽
|
||||
* 单槽会在两条分享挤在同一窗口时**静默丢掉前一条**(比如未登录时先后从相册和浏览器各分享一次)。
|
||||
* 队列让它们都留在里面,按收到的顺序逐条落地。
|
||||
*
|
||||
* 内容只在**进程内存**里:它跨得过登录,跨不过进程被杀。为那一步准备的落盘副本由
|
||||
* `app/ShareIntake.kt` 在下次启动时清理。
|
||||
*/
|
||||
class PendingShareHolder<T> {
|
||||
|
||||
private val state = MutableStateFlow<List<T>>(emptyList())
|
||||
|
||||
/** 还没落地的内容(按收到顺序);空列表 = 没有待办。 */
|
||||
val pending: StateFlow<List<T>> = state.asStateFlow()
|
||||
|
||||
/** 追加一条(后到的排在后面:先分享的先落地)。 */
|
||||
fun put(value: T) = state.update { it + value }
|
||||
|
||||
/**
|
||||
* 取走队首并出队;队列为空时回 `null`。
|
||||
*
|
||||
* `getAndUpdate` 的 CAS 循环保证「取到的队首」就是**本次转换**的旧值:两个线程同时调用,
|
||||
* 一个拿到第一条、另一个拿到第二条,不会双双拿到同一条,也不会漏掉一条。
|
||||
*/
|
||||
fun take(): T? = state.getAndUpdate { it.drop(1) }.firstOrNull()
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package top.yeij.cyrene.domain.share
|
||||
|
||||
/**
|
||||
* 从别的 App 分享进来、已经可以落地的内容。
|
||||
*
|
||||
* [images] / [documents] 里的 URI 在**提取阶段**还是对方给的原始 URI;由 `app/ShareIntake.kt`
|
||||
* 拷进缓存后换成**本端自己**的 `file://`:外部 `content://` 的读授权只在这个 intent 的接收方
|
||||
* (Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以必须在收到的那一刻就落盘。
|
||||
*
|
||||
* 丢件计数按**两条路分开记**(图片 / 文档),因为「本端的接收上限」与「对方给的东西读不到」
|
||||
* 是两句不同的话,而图片与文档的上限也不是同一个数。分享进来的内容**不许静默少发**。
|
||||
*
|
||||
* 还有第三件要说的:[contentUnreadable]——对方给了东西、本端**连地址都没认下来**。它连丢件计数
|
||||
* 都留不下(本端一条都没接收),所以单独一格;否则「分享点下去什么都不发生」正是它造成的。
|
||||
*/
|
||||
data class SharedContent(
|
||||
val text: String = "",
|
||||
val images: List<String> = emptyList(),
|
||||
/**
|
||||
* 非图片的分享文件。MIME 以 `image/` 开头者走 [images],其余一律走这一条
|
||||
* (判定见 [classifySharedStream])——这正是修复「把一份 `.md` 当图片去解码、
|
||||
* 最后报一句『图片读不到』」的地方。
|
||||
*/
|
||||
val documents: List<SharedDocument> = emptyList(),
|
||||
/** 超过 [ShareRules.MAX_IMAGES] 而未被接收的图片条数。 */
|
||||
val truncatedImages: Int = 0,
|
||||
/** 已接收但**拷贝失败**(读不到 / 写不进 cache)的图片条数。 */
|
||||
val failedImages: Int = 0,
|
||||
/** 超过 [ShareRules.MAX_DOCUMENTS] 而未被接收的文件条数。 */
|
||||
val truncatedDocuments: Int = 0,
|
||||
/** 已接收但**拷贝失败**的文件条数。 */
|
||||
val failedDocuments: Int = 0,
|
||||
/**
|
||||
* 这次分享**带着内容迹象**(`EXTRA_STREAM` 有值、或 `ClipData` 有条目),却一条可用内容都没
|
||||
* 提取出来。
|
||||
*
|
||||
* 与 [failedImages] / [failedDocuments] **不是**同一件事:那两个说的是「本端已经认下来的地址
|
||||
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连地址都没认下来」(多为发送方把
|
||||
* `EXTRA_STREAM` 塞成了本端认不出的形态,或用的是本端不读的 scheme)。它是**唯一**一种既没有
|
||||
* 内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是「用户点了分享、界面毫无反应」。
|
||||
*/
|
||||
val contentUnreadable: Boolean = false,
|
||||
) {
|
||||
/** 没能落地的总条数。 */
|
||||
val droppedImages: Int get() = truncatedImages + failedImages
|
||||
|
||||
/** 没能落地的文件总条数。 */
|
||||
val droppedDocuments: Int get() = truncatedDocuments + failedDocuments
|
||||
|
||||
/** 没有任何可发送的内容(此时只剩下丢件计数需要告知)。 */
|
||||
val isEmpty: Boolean get() = text.isBlank() && images.isEmpty() && documents.isEmpty()
|
||||
|
||||
/**
|
||||
* 这条分享值不值得继续走:有可落地的内容,或有要对用户说清的事(丢件计数 / 读不进来)。
|
||||
*
|
||||
* 判据只此一处:`ShareIntake.accept` 用它决定要不要往下接,`shareNoticeFor` 用它守卫「什么都没有
|
||||
* 的分享一个字都不说」那条边界——两处必须同一条,否则会出现「接了却不说」或「说了却没接」。
|
||||
* 整条分享里什么都没有(`EXTRA_TEXT` 没有值、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目)
|
||||
* 时它是 `false`:那次分享确实无话可说,静默才是对的。
|
||||
*/
|
||||
val worthDelivering: Boolean
|
||||
get() = !isEmpty || droppedImages > 0 || droppedDocuments > 0 || contentUnreadable
|
||||
}
|
||||
|
||||
/**
|
||||
* 一份分享进来的**非图片**文件。
|
||||
*
|
||||
* [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、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]):那是本端**新开**的一条读入口,
|
||||
* 松不得。被拒的条目**逐条跳过**——一次分享里有几条坏的,不该把整次分享废掉。
|
||||
*
|
||||
* ## 为什么正文一个字都不加
|
||||
* **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem`
|
||||
* 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`),人类可见的标记只能放在本端
|
||||
* 渲染层。把标记拼进正文 = 让用户正文冒充系统指令,是项目红线。分享进来的正文**原样**发出。
|
||||
*/
|
||||
object ShareRules {
|
||||
|
||||
/** `Intent.ACTION_SEND`:一条文本,或一张图片 / 一个文件(可能还带一段说明文字)。 */
|
||||
const val ACTION_SEND = "android.intent.action.SEND"
|
||||
|
||||
/** `Intent.ACTION_SEND_MULTIPLE`:多条;本端 Manifest 只声明了图片那一类。 */
|
||||
const val ACTION_SEND_MULTIPLE = "android.intent.action.SEND_MULTIPLE"
|
||||
|
||||
/**
|
||||
* 一次分享最多接收几张图。
|
||||
*
|
||||
* 现有发送路径**一条消息只能带一张图**,所以 N 张图 = N 条消息(第一条带上分享的正文)。
|
||||
* 上限压在 4 是为了不让一次分享在会话里炸出一长串回合;超出的部分不静默丢,
|
||||
* 由调用方如实告知(见 [SharedContent.truncatedImages])。
|
||||
*/
|
||||
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
|
||||
|
||||
/**
|
||||
* 从分享 Intent 的原始字段提取内容。
|
||||
*
|
||||
* ## 优先顺序与去重(一次分享里多种来源并存时)
|
||||
* 顺序 = `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] 那个只放一条的字段)
|
||||
* @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 clipUris `ClipData` 各条目的 URI,纯文本条目是 `null`(本端**不读** `ClipData` 里的
|
||||
* 文本,所以「只有纯文本条目」等同于「读不进来」,用来判 [SharedContent.contentUnreadable])
|
||||
* @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是
|
||||
* **纯函数**,所以类型判定由调用方注入;默认「拿不到类型」⇒ 按文档路走
|
||||
* (见 [classifySharedStream],默认值就是「不猜」)。
|
||||
*/
|
||||
fun extract(
|
||||
action: String?,
|
||||
text: CharSequence? = null,
|
||||
stream: String? = null,
|
||||
streams: List<String>? = null,
|
||||
streamStrings: List<String> = emptyList(),
|
||||
streamPresent: 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()
|
||||
|
||||
// 两个字段一并收:单值 / 列表是历史形态(当年靠 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_STREAM 里躺着的原始值 / ClipData 的条目数),
|
||||
// 不是「认下来的地址」——「有值但一条都认不出」正是要说的那种情形,用后者判反倒会把它当成
|
||||
// 「什么都没分享」而闭嘴。
|
||||
val hadStreamValue = streamPresent ||
|
||||
!stream.isNullOrBlank() ||
|
||||
streams.orEmpty().any(String::isNotBlank) ||
|
||||
streamStrings.any(String::isNotBlank)
|
||||
|
||||
return SharedContent(
|
||||
text = body,
|
||||
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 || 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`——真正的类型由字节嗅探裁决,不靠扩展名)。
|
||||
*
|
||||
* **绝不**沿用外部 URI 里的路径 / 显示名:那是别人给的字符串,直接当文件名等于把路径穿越的
|
||||
* 口子接到自己的 cache 目录上。这个函数是纯的,所以它连 [token] 都不信——非法字符一律折成
|
||||
* `_`,`. .` 因此拼不出 `..`,`/` 也活不下来。
|
||||
*/
|
||||
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()) {
|
||||
"image/png" -> "png"
|
||||
"image/jpeg", "image/jpg" -> "jpg"
|
||||
"image/webp" -> "webp"
|
||||
"image/gif" -> "gif"
|
||||
"image/bmp", "image/x-ms-bmp" -> "bmp"
|
||||
"image/heic", "image/heif" -> "heic"
|
||||
else -> "img"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.conclusionParagraphs
|
||||
import top.yeij.cyrene.domain.admin.formatEpochMillis
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
|
||||
/**
|
||||
* 后台思考页:`GET /api/v1/admin/backthink`(**仅 `owner`/`admin`**)。
|
||||
* `at` 是 **Unix 毫秒**,与 trace 的 RFC3339 不同源,用 [formatEpochMillis] 单独解析。
|
||||
* `conclusion` 是**与聊天消息同一套 Web 端 markup**的原文(真实样本里有连写的空 `<p></p>` 与
|
||||
* `<action>`),因此不直接上屏:先经 [conclusionParagraphs] 解析成渲染模型,再逐段渲染。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminBackthinkScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.backthink.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Backthink, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Backthink.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
AdminLoadShell(
|
||||
state = state,
|
||||
onRetry = reload,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) { records ->
|
||||
if (records.isEmpty()) {
|
||||
AdminCenterHint("暂无后台思考记录")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = records, key = { it.id }) { record -> BackthinkCard(record) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackthinkCard(record: AdminBackthinkItemDto) {
|
||||
val paragraphs = remember(record.conclusion) { conclusionParagraphs(record.conclusion) }
|
||||
AdminRichTextCard(
|
||||
title = orDash(record.trigger),
|
||||
paragraphs = paragraphs,
|
||||
notice = backthinkNotice(record),
|
||||
trailing = if (record.at > 0) formatEpochMillis(record.at) else "-",
|
||||
)
|
||||
}
|
||||
|
||||
/** 结尾的推送状态行;与 `conclusion` 正文分开渲染,不参与 markup 解析。 */
|
||||
private fun backthinkNotice(record: AdminBackthinkItemDto): String =
|
||||
"通知 ${if (record.shouldNotify) "需推送" else "不推送"} · ${if (record.pushed) "已送达" else "未送达"}"
|
||||
@@ -0,0 +1,306 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.domain.admin.AdminFailure
|
||||
import top.yeij.cyrene.domain.admin.ConclusionParagraph
|
||||
import top.yeij.cyrene.domain.parse.ContentPart
|
||||
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.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.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
|
||||
import top.yukonga.miuix.kmp.icon.extended.Refresh
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 管理页统一外壳:独立的 Miuix [Scaffold] + 返回按钮,各页只填内容。
|
||||
* 管理员区是聊天之外的独立导航(design D15),所以每页都有自己的 Scaffold。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminPageScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SmallTopAppBar(
|
||||
title = title,
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(imageVector = MiuixIcons.ChevronBackward, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (onRefresh != null) {
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(imageVector = MiuixIcons.Refresh, contentDescription = "刷新")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AdminSectionTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.subtitle,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** 居中提示(加载中 / 空态 / 无权限),整页级别。 */
|
||||
@Composable
|
||||
fun AdminCenterHint(
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
if (detail != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = detail,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
if (onRetry != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextButton("重试", onClick = onRetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 区块内的行内提示(不占满高度,可放进可滚动列 / LazyColumn 的 item)。 */
|
||||
@Composable
|
||||
fun AdminInlineHint(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取数失败时的整页状态。
|
||||
* **无权限与其它失败必须分开**:403/401 给「无权限」+ 角色说明,绝不渲染成空列表。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminFailureState(
|
||||
failure: AdminFailure?,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (failure) {
|
||||
is AdminFailure.Forbidden -> AdminCenterHint(
|
||||
title = "无权限",
|
||||
detail = "当前角色不能读取该页,请用 owner 或 admin 账号登录。",
|
||||
)
|
||||
|
||||
else -> AdminCenterHint(
|
||||
title = "加载失败",
|
||||
detail = (failure as? AdminFailure.Other)?.message ?: "请稍后重试",
|
||||
onRetry = onRetry,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表/详情页共用的取数外壳:先判权限,再判加载,最后才渲染数据。
|
||||
* 顺序不能反——反了就会把 403 渲染成「暂无数据」。
|
||||
*/
|
||||
@Composable
|
||||
fun <T> AdminLoadShell(
|
||||
state: AdminUiState<T>,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val data = state.data
|
||||
Box(modifier = modifier) {
|
||||
when {
|
||||
state.failure is AdminFailure.Forbidden -> AdminFailureState(state.failure, onRetry)
|
||||
data == null && state.loading -> AdminCenterHint("加载中…")
|
||||
data == null -> AdminFailureState(state.failure, onRetry)
|
||||
else -> content(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 「左标签 + 右值」一行;必须放在 [PreferenceCard] 内。 */
|
||||
@Composable
|
||||
fun AdminValueRow(title: String, value: String, valueColor: Color? = null) {
|
||||
BasicComponent(
|
||||
title = title,
|
||||
endActions = {
|
||||
Text(
|
||||
text = value,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = valueColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 指标卡片:一组「标签 → 值」,单张卡片内多行。 */
|
||||
@Composable
|
||||
fun AdminMetricCard(rows: List<Pair<String, String>>) {
|
||||
PreferenceCard {
|
||||
Column {
|
||||
rows.forEachIndexed { index, (label, value) ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
}
|
||||
AdminValueRow(title = label, value = value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 卡片首行:左标题 + 右尾注(长尾注省略)。两种文本卡共用,外观一致。 */
|
||||
@Composable
|
||||
private fun AdminCardHeader(title: String, trailing: String?, trailingColor: Color?) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MiuixTheme.textStyles.main, modifier = Modifier.weight(1f))
|
||||
if (trailing != null) {
|
||||
Text(
|
||||
text = trailing,
|
||||
modifier = Modifier.widthIn(max = 160.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = trailingColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理与详情页里的普通文本块(长文本用:[PreferenceCard] 里一行标题 + 正文)。 */
|
||||
@Composable
|
||||
fun AdminTextCard(
|
||||
title: String,
|
||||
body: String,
|
||||
trailing: String? = null,
|
||||
trailingColor: Color? = null,
|
||||
) {
|
||||
PreferenceCard {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
AdminCardHeader(title, trailing, trailingColor)
|
||||
if (body.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = body,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 承载 **Web 端 markup 原文**的文本块:正文来自 [ConclusionParagraph] 渲染模型,逐段渲染。
|
||||
* 段与段之间用间距分隔(正文里不再出现拼接的 `\n`);段内片段按原文顺序同行排列,
|
||||
* 正文用卡片正文色,动作/旁白(`Action` / `Narration`)按聊天页的约定渲染成**灰斜体**
|
||||
* (见 `ui/chat/MessageBubble.kt` 的旁白行)。[notice] 是挂在段落下方的次要一行(灰、小字)。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminRichTextCard(
|
||||
title: String,
|
||||
paragraphs: List<ConclusionParagraph>,
|
||||
notice: String? = null,
|
||||
trailing: String? = null,
|
||||
trailingColor: Color? = null,
|
||||
) {
|
||||
PreferenceCard {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
AdminCardHeader(title, trailing, trailingColor)
|
||||
paragraphs.forEachIndexed { index, paragraph ->
|
||||
if (index == 0) Spacer(Modifier.height(4.dp)) else Spacer(Modifier.height(8.dp))
|
||||
ParagraphLine(paragraph)
|
||||
}
|
||||
if (!notice.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(if (paragraphs.isEmpty()) 4.dp else 8.dp))
|
||||
Text(
|
||||
text = notice,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 一段:片段同行渲染,正文正常色,动作/旁白灰斜体;片段之间按需补一个空格。 */
|
||||
@Composable
|
||||
private fun ParagraphLine(paragraph: ConclusionParagraph) {
|
||||
val narrationColor = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
val line = remember(paragraph, narrationColor) {
|
||||
buildAnnotatedString {
|
||||
paragraph.parts.forEachIndexed { index, part ->
|
||||
if (index > 0 && needsSpace(paragraph.parts[index - 1].text, part.text)) append(' ')
|
||||
when (part) {
|
||||
is ContentPart.Text -> append(part.text)
|
||||
is ContentPart.Action, is ContentPart.Narration -> withStyle(
|
||||
SpanStyle(color = narrationColor, fontStyle = FontStyle.Italic),
|
||||
) { append(part.text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = line,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainer,
|
||||
)
|
||||
}
|
||||
|
||||
/** 相邻片段要不要补空格:两边都没自带空白时才补,别在换行前留出多余空格。 */
|
||||
private fun needsSpace(previous: String, next: String): Boolean =
|
||||
previous.isNotEmpty() && next.isNotEmpty() &&
|
||||
!previous.last().isWhitespace() && !next.first().isWhitespace()
|
||||
@@ -0,0 +1,154 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
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.orDash
|
||||
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.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 管理只读监控(C-2):独立的顶层屏幕 + 独立 Scaffold,**不在聊天界面里**(design D15)。
|
||||
*
|
||||
* 结构是「入口页列出可用的管理页 → 逐页进入」的**可扩展壳**:页清单由
|
||||
* [AdminPage] 驱动,新增一个管理模块只需在清单里加一项、在 [AdminPageScreen] 里加一个分支,
|
||||
* 不需要改动本文件的布局。
|
||||
*
|
||||
* 权限降级(api-client §2.0):入口按角色禁用越权页(**不发请求**),
|
||||
* ViewModel 再做一次同样的短路,服务端返回 403/401 时还有第三层「无权限」状态。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminHomeScreen(
|
||||
container: AppContainer,
|
||||
onBack: () -> Unit = {},
|
||||
) {
|
||||
val session by container.authManager.state.collectAsState()
|
||||
val rawRole = (session as? SessionState.LoggedIn)?.role
|
||||
val role = AdminRole.of(rawRole)
|
||||
var openPage by remember { mutableStateOf<AdminPage?>(null) }
|
||||
val viewModel: AdminViewModel = viewModel(
|
||||
factory = viewModelFactory { initializer { AdminViewModel(container) } },
|
||||
)
|
||||
|
||||
val page = openPage
|
||||
BackHandler(enabled = page != null) { openPage = null }
|
||||
|
||||
if (page == null) {
|
||||
AdminIndexScreen(
|
||||
rawRole = rawRole,
|
||||
role = role,
|
||||
onOpen = { openPage = it },
|
||||
onBack = onBack,
|
||||
)
|
||||
} else {
|
||||
AdminPageScreen(
|
||||
page = page,
|
||||
role = role,
|
||||
viewModel = viewModel,
|
||||
onBack = { openPage = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理入口页:列出全部监控页,越权页显示为禁用的「无权限」行而不是隐藏。 */
|
||||
@Composable
|
||||
private fun AdminIndexScreen(
|
||||
rawRole: String?,
|
||||
role: AdminRole,
|
||||
onOpen: (AdminPage) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
AdminPageScaffold(title = "管理员监控", onBack = onBack) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = "只读监控 · 当前角色 ${orDash(rawRole)}",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp),
|
||||
)
|
||||
AdminSectionTitle("监控页")
|
||||
AdminPage.entries.forEach { page ->
|
||||
PreferenceCard { AdminPageRow(page = page, role = role, onOpen = onOpen) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdminPageRow(
|
||||
page: AdminPage,
|
||||
role: AdminRole,
|
||||
onOpen: (AdminPage) -> Unit,
|
||||
) {
|
||||
if (canAccess(role, page)) {
|
||||
BasicComponent(
|
||||
title = page.title,
|
||||
summary = page.summary,
|
||||
onClick = { onOpen(page) },
|
||||
endActions = {
|
||||
Icon(imageVector = MiuixIcons.ChevronForward, contentDescription = null)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
BasicComponent(
|
||||
title = page.title,
|
||||
summary = "需要 owner 或 admin 权限",
|
||||
endActions = {
|
||||
Text(
|
||||
text = "无权限",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理页分发:每个页在自己的文件里带 Scaffold,这里只做映射。 */
|
||||
@Composable
|
||||
private fun AdminPageScreen(
|
||||
page: AdminPage,
|
||||
role: AdminRole,
|
||||
viewModel: AdminViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
when (page) {
|
||||
AdminPage.Overview -> AdminOverviewScreen(viewModel, role, onBack)
|
||||
AdminPage.Personas -> AdminPersonaScreen(viewModel, role, onBack)
|
||||
AdminPage.Memories -> AdminMemoryScreen(viewModel, role, onBack)
|
||||
AdminPage.Backthink -> AdminBackthinkScreen(viewModel, role, onBack)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.memoryScopeLabel
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
|
||||
/**
|
||||
* 记忆页:`GET /api/v1/admin/memories`(**仅 `owner`/`admin`**,含私人记忆正文)。
|
||||
* `viewer` 进不来:入口已禁用、ViewModel 也短路,服务端还会 403。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminMemoryScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.memories.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Memories, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Memories.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
AdminLoadShell(
|
||||
state = state,
|
||||
onRetry = reload,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) { memories ->
|
||||
if (memories.isEmpty()) {
|
||||
AdminCenterHint("暂无记忆")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = memories, key = { it.id }) { memory ->
|
||||
AdminTextCard(
|
||||
// scope 是开放集合:未知取值原样显示。
|
||||
title = orDash(memoryScopeLabel(memory.scope)),
|
||||
body = memory.text,
|
||||
trailing = memory.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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
|
||||
import top.yeij.cyrene.domain.admin.formatProps
|
||||
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「可观测性契约」与 §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()
|
||||
.padding(padding),
|
||||
) {
|
||||
when {
|
||||
failure is AdminFailure.Forbidden -> AdminFailureState(failure, reload)
|
||||
|
||||
data == null && state.loading -> AdminCenterHint("加载中…")
|
||||
|
||||
// 三块都没有可画的东西才整页报错;只要有一路成功,就让它画出来、
|
||||
// 失败的区块各自说明自己(把一次成功藏在一句「加载失败」后面是另一种撒谎)。
|
||||
data == null || (data.metrics == null && data.trace.isEmpty() && data.logs == null) ->
|
||||
AdminFailureState(failure, reload)
|
||||
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
) {
|
||||
item(key = "__metrics__") {
|
||||
Column {
|
||||
AdminSectionTitle("指标")
|
||||
if (data.metrics != null) {
|
||||
AdminMetricCard(metricRows(data.metrics))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
AdminMetricCard(countRows(data.metrics))
|
||||
} else {
|
||||
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 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。
|
||||
// 这里只看 **trace 自己**的失败:日志那边挂了不该让这块说「不可用」。
|
||||
data.traceFailure != null -> item(key = "__trace_error__") {
|
||||
AdminTextCard("trace 不可用", failureMessage(data.traceFailure))
|
||||
}
|
||||
|
||||
data.trace.isEmpty() -> item(key = "__trace_empty__") {
|
||||
AdminInlineHint("暂无 trace span")
|
||||
}
|
||||
|
||||
else -> items(items = data.trace, key = { it.id }) { span ->
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun metricRows(metrics: AdminMetricsDto): List<Pair<String, String>> = listOf(
|
||||
"总请求数" to metrics.totalRequests.toString(),
|
||||
"QPS(近 1 秒)" to formatDouble(metrics.qps),
|
||||
"平均延迟" to "${formatDouble(metrics.avgLatencyMs)} ms",
|
||||
"最大延迟" to "${metrics.maxLatencyMs} ms",
|
||||
"LLM 调用" to metrics.llmCalls.toString(),
|
||||
"子代理事件" to metrics.subagentEvents.toString(),
|
||||
)
|
||||
|
||||
/** 「按方法」「状态码」两类计数仍是一行文本:它们最多三五条,分组反而更碎。 */
|
||||
private fun countRows(metrics: AdminMetricsDto): List<Pair<String, String>> = listOf(
|
||||
"按方法" to formatCounts(metrics.perMethod).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(
|
||||
title = span.name.ifBlank { span.id },
|
||||
body = traceBody(span),
|
||||
trailing = orDash(traceStatusLabel(span.status)),
|
||||
trailingColor = if (span.status == "error") MiuixTheme.colorScheme.error else null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志一行:消息是主体,来源 / 时间作副文本,等级作尾注。
|
||||
*
|
||||
* 等级只给 `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)}")
|
||||
span.endedAt?.let { add("结束 ${formatRfc3339(it) ?: it}") }
|
||||
span.parentId?.let { add("父 span $it") }
|
||||
formatProps(span.props)?.let { add("props $it") }
|
||||
span.events?.forEach { event ->
|
||||
val props = formatProps(event.props)?.let { " $it" }.orEmpty()
|
||||
add("事件 ${event.type} @ ${formatRfc3339(event.time) ?: event.time}$props")
|
||||
}
|
||||
}.joinToString("\n")
|
||||
|
||||
private fun failureMessage(failure: AdminFailure?): String =
|
||||
(failure as? AdminFailure.Other)?.message ?: "请求失败,请重试"
|
||||
@@ -0,0 +1,117 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.PersonaThinkingDto
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.moodLabel
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
import top.yeij.cyrene.domain.admin.personaModeLabel
|
||||
import top.yeij.cyrene.domain.admin.triStateLabel
|
||||
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 人格页:`GET /api/v1/admin/personas`(`viewer` 可读),含 admin 专属 `thinking` 旋钮。 */
|
||||
@Composable
|
||||
fun AdminPersonaScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.personas.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Personas, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Personas.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
AdminLoadShell(
|
||||
state = state,
|
||||
onRetry = reload,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) { personas ->
|
||||
if (personas.isEmpty()) {
|
||||
AdminCenterHint("暂无人格")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = personas, key = { it.id }) { PersonaCard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonaCard(persona: PersonaSummaryDto) {
|
||||
val catalog = LocalEnumCatalog.current
|
||||
PreferenceCard {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(persona.name.ifBlank { persona.id }, style = MiuixTheme.textStyles.main)
|
||||
if (persona.isDefault) {
|
||||
Text(
|
||||
text = " · 默认",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
PersonaDetail("id", persona.id)
|
||||
PersonaDetail("character", persona.character.ifBlank { "-" })
|
||||
// mode 是开放集合:权威表里没有的取值原样显示。
|
||||
PersonaDetail("mode", personaModeLabel(persona.mode, catalog).ifBlank { "-" })
|
||||
PersonaDetail("记忆域", persona.memoryNamespace.ifBlank { "-" })
|
||||
// mood 为空表示「该角色暂无被跟踪的情绪」,不伪造默认值。
|
||||
PersonaDetail(
|
||||
"情绪",
|
||||
persona.mood?.takeIf { it.isNotBlank() }?.let { moodLabel(it, catalog) } ?: "暂无",
|
||||
)
|
||||
PersonaDetail("思考", thinkingLabel(persona.thinking))
|
||||
persona.avatar?.let { PersonaDetail("avatar", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonaDetail(label: String, value: String) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "$label:$value",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `thinking` 未配置时该键**完全不出现**(不是全零对象);已配置时 `showToUser` 是三态,
|
||||
* null 必须显示成「未配置」,不能拍平成「不显示」。
|
||||
*/
|
||||
private fun thinkingLabel(thinking: PersonaThinkingDto?): String {
|
||||
if (thinking == null) return "未配置"
|
||||
return listOf(
|
||||
"budgetTokens ${thinking.budgetTokens}",
|
||||
"effort ${orDash(thinking.effort)}",
|
||||
"mode ${orDash(thinking.mode)}",
|
||||
"showToUser ${triStateLabel(thinking.showToUser)}",
|
||||
).joinToString(" · ")
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.async
|
||||
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
|
||||
|
||||
/**
|
||||
* 单个管理页的取数状态。
|
||||
* [data] 为 null 表示**还没拿到**(加载中或失败),空集合表示**确实没有数据**——
|
||||
* 两者在 UI 上必须分开,否则 403 会伪装成「暂无数据」。
|
||||
*/
|
||||
data class AdminUiState<T>(
|
||||
val loading: Boolean = false,
|
||||
val data: T? = null,
|
||||
val failure: AdminFailure? = null,
|
||||
)
|
||||
|
||||
/** 概览页: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,
|
||||
)
|
||||
|
||||
/**
|
||||
* 管理只读监控的四页取数(api-client §2)。
|
||||
*
|
||||
* [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() {
|
||||
|
||||
private val _overview = MutableStateFlow(AdminUiState<AdminOverviewData>())
|
||||
val overview: StateFlow<AdminUiState<AdminOverviewData>> = _overview.asStateFlow()
|
||||
|
||||
private val _personas = MutableStateFlow(AdminUiState<List<PersonaSummaryDto>>())
|
||||
val personas: StateFlow<AdminUiState<List<PersonaSummaryDto>>> = _personas.asStateFlow()
|
||||
|
||||
private val _memories = MutableStateFlow(AdminUiState<List<MemorySummaryDto>>())
|
||||
val memories: StateFlow<AdminUiState<List<MemorySummaryDto>>> = _memories.asStateFlow()
|
||||
|
||||
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)) {
|
||||
deny(page)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (page) {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 概览页三路请求并发;任一 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, logs) = coroutineScope {
|
||||
val metricsCall = async { repository.metrics() }
|
||||
val traceCall = async { repository.trace() }
|
||||
// 日志历史按其角色的档位决定读不读(viewer 也读得到);不够档的页在 load() 就被拦下了。
|
||||
val logsCall = if (canReadObservabilityLogHistory(role)) async { repository.logs() } else null
|
||||
Triple(metricsCall.await(), traceCall.await(), logsCall?.await())
|
||||
}
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T> loadList(
|
||||
target: MutableStateFlow<AdminUiState<List<T>>>,
|
||||
fetch: suspend () -> Result<List<T>>,
|
||||
) {
|
||||
target.value = target.value.copy(loading = true, failure = null)
|
||||
fetch()
|
||||
.onSuccess { list -> target.value = AdminUiState(data = list) }
|
||||
.onFailure { e -> target.value = AdminUiState(failure = e.toAdminFailure()) }
|
||||
}
|
||||
|
||||
private fun deny(page: AdminPage) {
|
||||
when (page) {
|
||||
AdminPage.Overview -> _overview.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
AdminPage.Personas -> _personas.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
AdminPage.Memories -> _memories.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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
|
||||
import androidx.compose.runtime.remember
|
||||
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)随之变化 ——
|
||||
* 变的是 key,于是**承载横幅的那段组合被销毁重建**;那个 VM 实例本身住在 Activity 级的
|
||||
* ViewModelStore 里,**仍然活着、继续收帧**(连登出都不回收它,见 `chatViewModelKey` 的 KDoc)。
|
||||
* 横幅挂在 VM 状态上的话活不过这一帧、一个字都来不及显示(真机实测过)。
|
||||
* 这一层的 `remember` 活得比它久。
|
||||
*/
|
||||
private const val SESSION_GONE_MESSAGE = "这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话"
|
||||
|
||||
|
||||
/**
|
||||
* 聊天页 + 侧边栏(会话切换)+ 设置页 + 管理员监控。
|
||||
* 返回键逐层回退:管理/设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。
|
||||
*/
|
||||
@Composable
|
||||
fun ChatHomeScreen(
|
||||
container: AppContainer,
|
||||
onLogout: () -> Unit = {},
|
||||
) {
|
||||
val sessionsViewModel: SessionsViewModel = viewModel(
|
||||
factory = viewModelFactory { initializer { SessionsViewModel(container) } },
|
||||
)
|
||||
val state by sessionsViewModel.state.collectAsState()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = showAdmin) { showAdmin = false }
|
||||
// 「关于」嵌在设置页里,所以两个条件必须**互斥**:在关于页时设置页的处理器关掉,
|
||||
// 返回才只退一层(关于 → 设置),不会一步跳回聊天。
|
||||
BackHandler(enabled = showAbout) { showAbout = false }
|
||||
BackHandler(enabled = showSettings && !showAbout) { showSettings = false }
|
||||
BackHandler(enabled = drawerOpen) { drawerOpen = false }
|
||||
|
||||
when {
|
||||
showAdmin -> AdminHomeScreen(
|
||||
container = container,
|
||||
onBack = { showAdmin = false },
|
||||
)
|
||||
|
||||
showAbout -> AboutScreen(onBack = { showAbout = false })
|
||||
|
||||
showSettings -> SettingsScreen(
|
||||
container = container,
|
||||
onBack = { showSettings = false },
|
||||
onLogout = onLogout,
|
||||
onOpenAbout = { showAbout = true },
|
||||
)
|
||||
|
||||
else -> MiuixDrawer(
|
||||
open = drawerOpen,
|
||||
// 抽屉只会被外部入口打开(MiuixDrawer 的 onOpenChange 只回报关闭),
|
||||
// 所以这里不用管「打开」分支。
|
||||
onOpenChange = { open -> drawerOpen = open },
|
||||
content = {
|
||||
ChatScreen(
|
||||
container = container,
|
||||
sessionId = state.activeSessionId,
|
||||
onOpenDrawer = {
|
||||
drawerOpen = true
|
||||
// 抽屉打开时静默重拉:列表只在 init / 新建 / 删除时取过,而服务端的
|
||||
// 自动标题与置顶顺序**没有任何 WS 帧**会推过来,只能靠这里对齐。
|
||||
// 静默是为了不闪「同步中…」——屏幕上本来就有内容。
|
||||
sessionsViewModel.refresh(silent = true)
|
||||
},
|
||||
// 当前会话在别处被删了(`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 = {
|
||||
SessionDrawerContent(
|
||||
state = state,
|
||||
onSelect = { sessionId ->
|
||||
sessionsViewModel.select(sessionId)
|
||||
drawerOpen = false
|
||||
},
|
||||
onCreate = sessionsViewModel::createSession,
|
||||
onDelete = { sessionsViewModel.deleteSession(it.id) },
|
||||
onTogglePin = { sessionsViewModel.setPinned(it.id, !it.pinned) },
|
||||
onRename = { sessionId, title -> sessionsViewModel.renameSession(sessionId, title) },
|
||||
onOpenIot = { /* P2:IoT 页面 */ },
|
||||
onOpenAdmin = {
|
||||
drawerOpen = false
|
||||
showAdmin = true
|
||||
},
|
||||
onOpenSettings = {
|
||||
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,240 @@
|
||||
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.DocumentTarget
|
||||
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,是最容易被误读的一种失败。
|
||||
*
|
||||
* @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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 正文区:按行分块、逐块可选中,块数上限由 [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,465 @@
|
||||
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.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)) },
|
||||
)
|
||||
}
|
||||
|
||||
// 保存到下载目录:与附件那条路同一取舍——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 = state as? ImagePreviewState.Ready,
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部那一列出口(与附件弹层同一形态)。
|
||||
*
|
||||
* 字节还没到位([ImagePreviewState.Loading] / [ImagePreviewState.Failed])时两条出口**不给点**:
|
||||
* 点了也只能失败,不如让它们明摆着还没准备好。但**关闭**任何时候都在(右上角那个按钮)。
|
||||
*/
|
||||
@Composable
|
||||
private fun ExitPane(
|
||||
ready: ImagePreviewState.Ready?,
|
||||
notice: DocumentPreviewNotice?,
|
||||
note: String,
|
||||
onOpen: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp, bottom = 12.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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.runtime.Composable
|
||||
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.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.domain.chat.DocumentChipModel
|
||||
import top.yeij.cyrene.domain.chat.DocumentChips
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.chat.ImageHostRebase
|
||||
import top.yeij.cyrene.domain.chat.TurnPhase
|
||||
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
|
||||
import top.yeij.cyrene.domain.parse.ContentPart
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
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.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.basic.Close
|
||||
import top.yukonga.miuix.kmp.icon.extended.More
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 流式进行中的光标。 */
|
||||
private const val CURSOR = "▍"
|
||||
|
||||
/** 气泡圆角(正文、图片、附件芯片三处各自的形状里,只有这一处是气泡本体)。 */
|
||||
private val BubbleShape = RoundedCornerShape(18.dp)
|
||||
|
||||
/** 角标行里那枚「⋯」的大小;刻意小于 Miuix 的最小交互尺寸,别把每条消息的角标行撑高。 */
|
||||
private val MESSAGE_ACTION_ICON_SIZE = 18.dp
|
||||
|
||||
/** 「⋯」与来源角标之间的间距。 */
|
||||
private val MESSAGE_ACTION_GAP = 4.dp
|
||||
|
||||
/** 「⋯」的读屏名字,同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */
|
||||
private const val MESSAGE_ACTION_DESCRIPTION = "消息操作"
|
||||
|
||||
/** 长按气泡的读屏名字:说清按下去会发生什么(不长按就永远不知道)。 */
|
||||
private const val MESSAGE_ACTION_LONG_PRESS_LABEL = "打开这条消息的操作菜单"
|
||||
|
||||
/**
|
||||
* 「这条消息什么都没传过来」时写在气泡里的那句话。
|
||||
*
|
||||
* 只陈述**本端确定的事实**(内容没有到达本端),**不猜原因**:不说「对方删了」、也不说「这个渠道
|
||||
* 不支持」——那两句我们都没有证据(真正的根因在服务端,已登记 `devdocs/02` 的「IM 渠道的文件没有挂到
|
||||
* 消息的 documents 上」那一条)。措辞见 [ContentlessNote]。
|
||||
*/
|
||||
private const val CONTENTLESS_MESSAGE_NOTE = "这条消息的内容没有被传过来"
|
||||
|
||||
/**
|
||||
* 这条消息**什么都没有**:正文是空的、没有图、没有附件,也没有思维链。
|
||||
*
|
||||
* 判据刻意**窄**,只有 `user` 消息才可能成立:
|
||||
* - 任何一项非空都不成立——`content` 里哪怕只有一个空格也算有内容(`isBlank` 只吃掉纯空白);
|
||||
* - `assistant` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说),
|
||||
* 顺手改会波及所有助手气泡,不在本次范围内。
|
||||
*
|
||||
* 为什么需要它:微信等 IM 渠道进来的文件**没有挂到消息的 `documents` 上**(`documents` 键整个缺席、
|
||||
* `content` 也是空),而昔涟那边确实读到了内容。客户端拿到手的真是一条什么都没有的消息,此前渲染成
|
||||
* **空气泡**——看的人只能猜。这一条判据就是「要如实说一句」的那个入口。
|
||||
*/
|
||||
fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
message.role == "user" &&
|
||||
message.content.isBlank() &&
|
||||
message.image.isNullOrBlank() &&
|
||||
message.documents.isEmpty() &&
|
||||
message.thinking.isNullOrBlank()
|
||||
|
||||
/**
|
||||
* 一条落库消息渲染成一个或多个气泡:
|
||||
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
|
||||
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
|
||||
* - 其它 mode 原样整条渲染,不解析标签;
|
||||
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
|
||||
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
|
||||
* - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过
|
||||
* [ImageHostRebase](见 [imageBaseUrl]);
|
||||
* - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明
|
||||
* ([ContentlessNote])——空气泡是最糟的一种呈现:用户完全不知道发生了什么。
|
||||
*
|
||||
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
|
||||
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
|
||||
*
|
||||
* ## 编辑 / 删除的两个入口(D3)
|
||||
* - **长按气泡**(`combinedClickable`)打开动作菜单;
|
||||
* - 气泡下方那一行里的 **「⋯」**(content-desc「消息操作」)打开**同一个**菜单。
|
||||
*
|
||||
* 为什么要两个入口:气泡里的正文被 [SelectionContainer] 包着,而**长按正文是「选字」的手势**
|
||||
* (两者抢同一个长按,谁先消费由运行时决定,不可靠)——只留长按的话,用户最自然会按的那块区域
|
||||
* 恰恰可能不响。因此多给一个看得见、点得着的入口,长按照旧保留(气泡内边距、附件芯片、
|
||||
* 图片这些不参与选字的区域按下去就是菜单)。
|
||||
*
|
||||
* 哪些动作可用由纯函数给出([canEditMessageOnServer] / [canDeleteMessageOnServer]:角色口径
|
||||
* 加上「这条在服务端存在吗」),与 VM 里那道闸是同一份判据;两个都不成立时两个入口都不出现
|
||||
* ——未知 `role` 是一档,**本端自铸的降级气泡**(`proactive-local:` 前缀,服务端根本没有这一行)
|
||||
* 是另一档。后者只是不给入口:气泡的**选字**照旧(它是 `SelectionContainer` 那条独立的路,
|
||||
* 与动作菜单无关)。
|
||||
*
|
||||
* @param imageBaseUrl 当前的服务器地址(`AuthManager.currentBaseUrl`)。只用来把历史图片里**绑死在
|
||||
* 上传那一刻 host 上的同源绝对地址**换到现在的 host(`02/F6①`:`POST /upload` 的 `url` 按请求时的
|
||||
* `r.Host` 拼、且原样落库 ⇒ 换地址后旧图取不回)。空串 = 不替换([ImageHostRebase] 会原样返回)。
|
||||
* @param phase 这条消息在**太忙时并行六态**里的哪一态(见 [ChatUiState.phaseOf] / 规范 §1.1)。
|
||||
* 只有 ③ 排队中与 ④ 回答中改变外观,①②⑤ 一律按今天的样子画(所以老服务端上逐字不变)。
|
||||
* @param legBadge 「并行路 N」小标(**真的是并行路**且 `leg >= 2` 才有,见 [legBadgeOf])。挂在来源角标那一行里:
|
||||
* 规范 §1.4 A 要的就是「同一路的两条消息在界面上看得出来是一路」——提问侧挂同一个标,
|
||||
* 回复侧在 [StreamingBubble] 上挂一次,用户就能把它们配起来。
|
||||
* @param onEdit 用户选中「编辑」:交给上层打开编辑弹层(本组件不自己发请求)。
|
||||
* @param onDelete 用户选中「删除」:交给上层打开删除确认(不可恢复,必须二次确认)。
|
||||
*/
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
message: HistoryMessageDto,
|
||||
imageBaseUrl: String = "",
|
||||
phase: TurnPhase = TurnPhase.Sent,
|
||||
legBadge: String? = null,
|
||||
onDocumentClick: (DocumentRefDto) -> Unit = {},
|
||||
onImageClick: (String) -> Unit = {},
|
||||
onEdit: (HistoryMessageDto) -> Unit = {},
|
||||
onDelete: (HistoryMessageDto) -> Unit = {},
|
||||
) {
|
||||
val isUser = message.role == "user"
|
||||
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
|
||||
// 表只能给「id → 标签」,说不出哪个 mode 是「按 markup 切气泡的聊天式渲染」,
|
||||
// 所以这条字面量刻意保留为客户端渲染契约,不表驱动。
|
||||
val isChat = message.mode.isNullOrBlank() || message.mode == "chat"
|
||||
val bubbles = remember(message.content, isChat) {
|
||||
val parsed = if (isChat) parse(message.content).filterNot { it.isEmpty } else emptyList()
|
||||
parsed.ifEmpty { listOf(plainBubble(message.content)) }
|
||||
}
|
||||
|
||||
val editable = canEditMessageOnServer(message)
|
||||
val deletable = canDeleteMessageOnServer(message)
|
||||
val canAct = editable || deletable
|
||||
// 每个气泡各自记住菜单开着没有:重建 / 滚动复用不会把上一个气泡的菜单带过来。
|
||||
var menuOpen by remember(message.id) { mutableStateOf(false) }
|
||||
val openMenu = if (canAct) { { menuOpen = true } } else null
|
||||
// 灰态(规范 §1.1 ④「已读、未答」):正文区灰 + 光标指示。判据只认 `Answering` 一档,
|
||||
// 所以 ③ 排队中**不会**被渲染成灰态——规范 §1.3 特意点名不要合并这两态。
|
||||
val gray = phase == TurnPhase.Answering
|
||||
|
||||
MessageGroup(
|
||||
isUser = isUser,
|
||||
footer = bubbleFooter(
|
||||
sourceLabel = messageSourceLabel(message, LocalEnumCatalog.current),
|
||||
phase = phase,
|
||||
legBadge = legBadge,
|
||||
shared = isSharedMessage(message),
|
||||
),
|
||||
onOpenMenu = openMenu,
|
||||
) {
|
||||
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
|
||||
DocumentChipRow(
|
||||
chips = message.documents.map(DocumentChips::of),
|
||||
alignEnd = isUser,
|
||||
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
|
||||
)
|
||||
message.image?.takeIf { it.isNotBlank() }?.let { ImageHostRebase.apply(it, imageBaseUrl) }
|
||||
?.let { ChatImage(it, onImageClick) }
|
||||
if (isContentlessUserMessage(message)) {
|
||||
// 空气泡是最糟的一种呈现:用户完全不知道发生了什么。这里如实说一句(见 [ContentlessNote])。
|
||||
ContentlessNote(onLongPress = openMenu)
|
||||
} else {
|
||||
bubbles.forEachIndexed { index, bubble ->
|
||||
Bubble(
|
||||
bubble = bubble,
|
||||
isUser = isUser,
|
||||
isLast = index == bubbles.lastIndex,
|
||||
showCursor = gray,
|
||||
onLongPress = openMenu,
|
||||
gray = gray,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (menuOpen) {
|
||||
MessageActionMenu(
|
||||
editable = editable,
|
||||
deletable = deletable,
|
||||
onEdit = {
|
||||
menuOpen = false
|
||||
onEdit(message)
|
||||
},
|
||||
onDelete = {
|
||||
menuOpen = false
|
||||
onDelete(message)
|
||||
},
|
||||
onDismiss = { menuOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「这条是分享进来的」那枚角标。
|
||||
*
|
||||
* 它只是**一枚脚注**,与来源角标同一行、同一套字级与颜色(`footnote2` +
|
||||
* `onSurfaceVariantSummary`),刻意不做成可点的小药丸——[LegBadge] 那种样式代表「这里有个入口」,
|
||||
* 而这一枚只是说明,点它无事发生。
|
||||
*/
|
||||
const val SHARED_MESSAGE_BADGE = "[用户分享]"
|
||||
|
||||
/** 六态 ③ 的角标文案(规范 §1.1:「排队 ≠ 被受理」,语气与「发送中…」并列、不与「已读」混同)。 */
|
||||
const val QUEUED_BADGE = "排队中"
|
||||
|
||||
/**
|
||||
* 「这条还没连上、留在本端等连接」的小标(W40):接在 [QUEUED_BADGE] 后面,合成
|
||||
* 「排队中 · 还没连上」。
|
||||
*
|
||||
* 为什么要在气泡上(而不只是那一句柔和告知):告知八秒就收走了,而「这条还在等连接」是个会持续
|
||||
* 几十秒的**状态**——牌子撤掉之后,光看「排队中」分不出这是「她已经收下、只是前面还排着队」
|
||||
* 还是「连都没连上、一个字节都没上路」。两者对用户意味着不同的事(前者会照常回复,后者要等网络),
|
||||
* 所以状态本身要一直挂在气泡上。
|
||||
*
|
||||
* 语气刻意与 [QUEUED_BADGE] 一致(都是陈述,不是警告):没连上不是错误,内容也一条没丢。
|
||||
*/
|
||||
const val AWAITING_CONNECTION_BADGE = "还没连上"
|
||||
|
||||
/**
|
||||
* 乐观气泡下方那一行小标的文案(**纯函数**,可单测)。
|
||||
*
|
||||
* [awaitingConnection] 只在 ③ 上成句,而且是**加在既有小标之后**的四个字(见
|
||||
* [AWAITING_CONNECTION_BADGE]):这条消息在服务端**还没有任何身份**(没入队、没落库),
|
||||
* 与「她忙、这条已受理排队」必须看得出区别。刻意**不新增第七态**([TurnPhase] 的六个取值是
|
||||
* W30 定下的口径,见 `ChatUiState.phaseOf`),所以这里只是给同一个 ③ 补一句更准的话。
|
||||
*
|
||||
* ① 发送中时它一定是 false(没连上就不可能是「发送中」),这里也按 `when` 的顺序把它挡在
|
||||
* ③ 之外:真的传进 `true` 也只会说「发送中…」——不为了一个不该发生的组合编一句话。
|
||||
*/
|
||||
fun pendingBubbleFooter(phase: TurnPhase, awaitingConnection: Boolean): String = when {
|
||||
phase != TurnPhase.Queued -> "发送中…"
|
||||
awaitingConnection -> "$QUEUED_BADGE · $AWAITING_CONNECTION_BADGE"
|
||||
else -> QUEUED_BADGE
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡下方那一行角标的文案(纯函数,可单测)。
|
||||
*
|
||||
* 四段内容各自独立,用「·」连起来:**并行路小标**(多路时才在)→ **六态说明**(只有 ③④ 成句)
|
||||
* → **分享角标**([SHARED_MESSAGE_BADGE],只有分享进来的那条才在)→ **来源角标**(既有规则,
|
||||
* 见 [messageSourceLabel])。分享角标紧挨来源角标:两者回答的都是「这条从哪来」,
|
||||
* 挨着放读起来是一件事的两面(「你分享进来的 · 来自 安卓」)。
|
||||
*
|
||||
* 为什么 ③④ 要在这里说话:它们**都不是错误、也都不是「已送达」**,而气泡本身在两种态下都长得像
|
||||
* 一条正常消息。不说一句,用户就分不清「她还没开始理我」与「已经在回答我了」——
|
||||
* 规范 §1.1 特意把这两态分开,界面上就必须能分开。
|
||||
*
|
||||
* [sourceLabel] 由调用方从组合里取(`messageSourceLabel(message, LocalEnumCatalog.current)`):
|
||||
* 那个 composition local **只能在可组合函数里读**,所以这里收进来当参数,好让本函数保持纯粹、
|
||||
* 能被单测直接钉死。
|
||||
*
|
||||
* [shared] 同样由调用方判([isSharedMessage]——它已经含了「只认用户侧」那道 role 闸)。
|
||||
* 判据不在这函数里,是为了让它继续只做「把几个既成事实拼成一句话」这一件事。
|
||||
*/
|
||||
fun bubbleFooter(
|
||||
sourceLabel: String?,
|
||||
phase: TurnPhase,
|
||||
legBadge: String?,
|
||||
shared: Boolean = false,
|
||||
): String? {
|
||||
val parts = buildList {
|
||||
legBadge?.let { add(it) }
|
||||
when (phase) {
|
||||
TurnPhase.Queued -> add(QUEUED_BADGE)
|
||||
TurnPhase.Answering -> add("已读,正在回答…")
|
||||
else -> Unit
|
||||
}
|
||||
if (shared) add(SHARED_MESSAGE_BADGE)
|
||||
sourceLabel?.let { add(it) }
|
||||
}
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* 乐观发送中的用户消息(还没落库)。
|
||||
*
|
||||
* 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、
|
||||
* 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。
|
||||
* 附件同理显示**本地显示名**(那时还没有服务端存储名,也还没有任何服务端结论)。
|
||||
* 这张本地图同样可点进全屏查看([onImageClick]):读的是本地那一份字节。
|
||||
*
|
||||
* [phase] 只会是 ① 发送中或 ③ 排队中(乐观气泡还没有服务端 id,绑不到任何一路——
|
||||
* 见 [ChatUiState.phaseOf])。③ 的角标文案按规范 §1.1 用「排队中」,语气与「发送中…」并列而不
|
||||
* 与「已读」混同:**排队 ≠ 被受理**(规范 §1.3)。
|
||||
*
|
||||
* [awaitingConnection] 是 ③ 里更细的那一档(W40:**本端**因为没连上把这条留住了,一个字节都还
|
||||
* 没上路),文案由纯函数 [pendingBubbleFooter] 给:接上「还没连上」四个字。判据在 VM 状态里
|
||||
* ([ChatUiState.isAwaitingConnection]),这里只负责画。
|
||||
*/
|
||||
@Composable
|
||||
fun PendingBubble(
|
||||
item: PendingMessage,
|
||||
phase: TurnPhase = TurnPhase.Sending,
|
||||
awaitingConnection: Boolean = false,
|
||||
onDocumentClick: (DraftDocument) -> Unit = {},
|
||||
onImageClick: (String) -> Unit = {},
|
||||
) {
|
||||
MessageGroup(
|
||||
isUser = true,
|
||||
footer = pendingBubbleFooter(phase, awaitingConnection),
|
||||
) {
|
||||
DocumentChipRow(
|
||||
chips = item.documents.map(DocumentChips::of),
|
||||
alignEnd = true,
|
||||
onClick = { index -> item.documents.getOrNull(index)?.let(onDocumentClick) },
|
||||
)
|
||||
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) }
|
||||
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件芯片排:**换行排布**——一枚放不下就落到下一行,N 枚都看得见。
|
||||
*
|
||||
* 它曾经是「横向可滚的单行 + `widthIn(max = 320.dp)`」,**实机一看就是坏的**(2026-10-04
|
||||
* 主人在主会话里发现):服务端存储名是 **32 位十六进制 + 扩展名**(`36a4aba0…5524808.txt`),
|
||||
* 一枚芯片就能吃掉 ~267dp,于是 320dp 的视口里第二枚只剩一条边露在屏幕外——看的人根本
|
||||
* 不知道还有第二个附件,也没人看得出那一行可以滚。换行之后 5 枚会占 2~3 行,比「藏起来」值。
|
||||
*
|
||||
* [alignEnd] = 用户消息(气泡在右侧):整排贴右;否则一枚短芯片会贴在该排左端、看着像错位。
|
||||
* [onRemove] 非空即带移除按钮——待发位(输入栏上方)要能反悔,历史气泡里则不给。
|
||||
* [onClick] 非空即整枚可点(附件预览的入口)。**点击区就是这一枚芯片本身**(`clip` + `background`
|
||||
* 那个 `Row`),不是里面的两行文字——否则手指落在芯片的空白处就没有反应。
|
||||
*
|
||||
* 附件可能是空列表(绝大多数消息都是),此时**不占位、不留空白**。
|
||||
*/
|
||||
@Composable
|
||||
fun DocumentChipRow(
|
||||
chips: List<DocumentChipModel>,
|
||||
modifier: Modifier = Modifier,
|
||||
alignEnd: Boolean = false,
|
||||
onRemove: ((Int) -> Unit)? = null,
|
||||
onClick: ((Int) -> Unit)? = null,
|
||||
) {
|
||||
if (chips.isEmpty()) return
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
space = 6.dp,
|
||||
alignment = if (alignEnd) Alignment.End else Alignment.Start,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
chips.forEachIndexed { index, chip ->
|
||||
DocumentChip(
|
||||
model = chip,
|
||||
onRemove = onRemove?.let { remove -> { remove(index) } },
|
||||
onClick = onClick?.let { click -> { click(index) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一枚附件芯片。
|
||||
*
|
||||
* 状态分两处说,**不许互相顶替**(契约 v0.59 §1.2):
|
||||
* - `status:"unreadable"` = **昔涟读不到这个文件** → 整枚转灰 + 明写「读不到」;
|
||||
* - `truncated:true` = **注入被预算截断**(正文照样进了上下文)→ 只加一句「注入已截断」。
|
||||
*
|
||||
* 未知 `status` 原样显示(开放集合,见 [DocumentChips.statusLabel])。
|
||||
*
|
||||
* [onClick] 非空时整枚可点(进附件预览),并带上 `contentDescription`——两行文字各自是可读的,
|
||||
* 但读屏需要知道「点它会发生什么」。
|
||||
*/
|
||||
@Composable
|
||||
private fun DocumentChip(model: DocumentChipModel, onRemove: (() -> Unit)?, onClick: (() -> Unit)?) {
|
||||
val clickModifier = if (onClick == null) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.semantics { contentDescription = "查看附件 ${model.name}" }
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (model.unreadable) {
|
||||
MiuixTheme.colorScheme.surfaceContainer
|
||||
} else {
|
||||
MiuixTheme.colorScheme.surfaceContainerHigh
|
||||
},
|
||||
)
|
||||
.then(clickModifier)
|
||||
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = model.name,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = if (model.unreadable) {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
} else {
|
||||
MiuixTheme.colorScheme.onSurfaceContainerHigh
|
||||
},
|
||||
maxLines = 1,
|
||||
// 存储名是 32 位十六进制 + 扩展名,长名字必须中略——从尾部截会把扩展名切掉,
|
||||
// 而扩展名正是这里唯一有语义的部分。
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
Text(
|
||||
text = model.detail,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
onRemove?.let { remove ->
|
||||
IconButton(onClick = remove) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Basic.Close,
|
||||
contentDescription = "移除附件 ${model.name}",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。
|
||||
*
|
||||
* ## 并行路小标(规范 §1.4 A / 主人裁决 ①)
|
||||
* [legBadge] 非空时在最上方挂一枚小标(「并行路 2」),点它跳到**这一路归属的那条提问**
|
||||
* ([onJumpToQuestion])。这就是规范给的单列形态:回复仍按历史顺序先后成条,靠小标而不是靠分栏
|
||||
* 表达「这是另起的一路」。**不是并行路**(`turn_begin.kind != "parallel"`)或单路(`leg < 2`)时
|
||||
* [legBadge] 为 `null`,**一个像素都不多画**——挂标这件事本身就是「这里有真正的多路」的信号
|
||||
* ([legBadgeOf] 的 KDoc)。
|
||||
*
|
||||
* 跳不到(那条提问不在已加载的窗口里、或这一路没绑消息)时**不显示「跳到提问」**:一个按下去
|
||||
* 什么都不发生的入口比没有入口更糟。[onJumpToQuestion] 传 `null` 即这一档。
|
||||
*/
|
||||
@Composable
|
||||
fun StreamingBubble(
|
||||
streaming: StreamingState,
|
||||
legBadge: String? = null,
|
||||
onJumpToQuestion: (() -> Unit)? = null,
|
||||
) {
|
||||
MessageGroup(isUser = false) {
|
||||
if (legBadge != null) {
|
||||
LegBadge(text = legBadge, onClick = onJumpToQuestion)
|
||||
}
|
||||
if (streaming.thinking.isNotBlank() || streaming.thinkingActive) {
|
||||
ThinkingCard(text = streaming.thinking, active = streaming.thinkingActive)
|
||||
}
|
||||
streaming.tools.forEach { ToolCard(it) }
|
||||
val bubbles = if (streaming.bubbles.isEmpty() && streaming.active) {
|
||||
listOf(plainBubble(CURSOR))
|
||||
} else {
|
||||
streaming.bubbles
|
||||
}
|
||||
bubbles.forEachIndexed { index, bubble ->
|
||||
Bubble(
|
||||
bubble = bubble,
|
||||
isUser = false,
|
||||
isLast = index == bubbles.lastIndex,
|
||||
showCursor = streaming.active,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageGroup(
|
||||
isUser: Boolean,
|
||||
footer: String? = null,
|
||||
onOpenMenu: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp),
|
||||
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
content()
|
||||
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)。
|
||||
// 「⋯」与它同一行:那枚图标是**看得见的**动作入口(长按同样能开,见 [MessageBubble])。
|
||||
if (footer != null || onOpenMenu != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(MESSAGE_ACTION_GAP),
|
||||
) {
|
||||
if (footer != null) {
|
||||
Text(
|
||||
text = footer,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
|
||||
)
|
||||
}
|
||||
if (onOpenMenu != null) {
|
||||
// 用 Icon + clickable 而不是 IconButton:后者的最小交互尺寸会把每一条消息的
|
||||
// 角标行撑到 48dp,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了
|
||||
// (长按气泡那条主路径仍然在)。
|
||||
Icon(
|
||||
imageVector = MiuixIcons.More,
|
||||
contentDescription = MESSAGE_ACTION_DESCRIPTION,
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier
|
||||
.size(MESSAGE_ACTION_ICON_SIZE)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onOpenMenu),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bubble(
|
||||
bubble: ParsedBubble,
|
||||
isUser: Boolean,
|
||||
isLast: Boolean,
|
||||
showCursor: Boolean = false,
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
gray: Boolean = false,
|
||||
) {
|
||||
AnnotationLine(bubble.head)
|
||||
val body = if (showCursor && isLast && bubble.body.isNotEmpty()) bubble.body + CURSOR else bubble.body
|
||||
if (body.isNotEmpty()) BubbleBody(body, isUser, onLongPress, gray)
|
||||
AnnotationLine(bubble.tail)
|
||||
}
|
||||
|
||||
/**
|
||||
* 并行路小标(规范 §1.4 A 的「小标」):一枚圆角小药丸,可点即跳。
|
||||
*
|
||||
* 配色刻意用 `primary` 文字 + `surfaceContainerHigh` 底:它既不是正文(不是内容),也不像来源角标
|
||||
* 那样只是脚注——它是**一个入口**。规范把「点击可跳到对应提问」列为推荐形态,所以能跳时它必须
|
||||
* 看得出可点([onClick] 非空才带涟漪与手势)。
|
||||
*/
|
||||
@Composable
|
||||
private fun LegBadge(text: String, onClick: (() -> Unit)?) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
|
||||
.then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (onClick == null) text else "$text · 跳到提问",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
|
||||
@Composable
|
||||
private fun AnnotationLine(parts: List<ContentPart>) {
|
||||
if (!LocalAppPrefs.current.showNarration) return
|
||||
val text = parts.joinToString(" ") { it.text }.trim()
|
||||
if (text.isEmpty()) return
|
||||
SelectionContainer(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「这条消息的内容没有被传过来」——**在气泡里**如实说一句(不是顶部横幅:那句话说的是这一条
|
||||
* 消息,挂在页面上就等于在说整页;而且横幅会自己消失,空气泡不会)。
|
||||
*
|
||||
* ## 为什么必须是灰底灰字
|
||||
* 它是**说明**,不是内容:正经气泡的配色(用户青色 / 助手灰底)会让这句话读起来像「他说的某一句话」,
|
||||
* 而事实恰恰是「这一条里什么都没有」。灰底 + 文件里既有的灰态小字([MiuixTheme.textStyles.footnote1]
|
||||
* + `onSurfaceVariantSummary`,与旁白那行同源)在两种角色下都读得清,也一眼看得出与正文不是一回事。
|
||||
*
|
||||
* ## 长按仍然开动作菜单
|
||||
* 这条消息在服务端是真实存在的一行,用户能做的事里最相关的一件就是**删掉它**(内容永远到不了了)。
|
||||
* 气泡没有正文可以让长按去选字,所以长按在这里没有歧义,直接接 [MessageBubble] 的那一个入口。
|
||||
*/
|
||||
@Composable
|
||||
private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.clip(BubbleShape)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainer)
|
||||
.then(
|
||||
if (onLongPress == null) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.combinedClickable(
|
||||
onLongClickLabel = MESSAGE_ACTION_LONG_PRESS_LABEL,
|
||||
onLongClick = onLongPress,
|
||||
onClick = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = CONTENTLESS_MESSAGE_NOTE,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡本体。
|
||||
*
|
||||
* [gray] = **灰态**(规范 §1.1 ④ 的「已读、未答」):底色一律退回中性的
|
||||
* `surfaceContainer`、文字退回 `onSurfaceVariantSummary`,**两种角色都画成同一种灰**——
|
||||
* 这一态的语义是「这条已经在被处理,但还没有答案」,它既不是用户说的话(不该是主人的青色),
|
||||
* 也不是她的回答(不该是助手的正常底色)。正文一个字都不改,只是**褪色**,让人一眼看出
|
||||
* 「没丢,只是在等」。
|
||||
*/
|
||||
@Composable
|
||||
private fun BubbleBody(
|
||||
text: String,
|
||||
isUser: Boolean,
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
gray: Boolean = false,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
// clip + background 与原来那句 `background(color, shape)` 画出来完全一样,
|
||||
// 多出来的这一层是为了让长按的涟漪被圆角裁住(否则它会溢出到方角外)。
|
||||
.clip(BubbleShape)
|
||||
.background(
|
||||
color = when {
|
||||
gray -> MiuixTheme.colorScheme.surfaceContainer
|
||||
isUser -> MiuixTheme.colorScheme.primary
|
||||
else -> MiuixTheme.colorScheme.surfaceContainer
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (onLongPress == null) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.combinedClickable(
|
||||
onLongClickLabel = MESSAGE_ACTION_LONG_PRESS_LABEL,
|
||||
onLongClick = onLongPress,
|
||||
// 短按不放任何功能:气泡上没有「点一下要做的事」,而涟漪仍然给得出
|
||||
// 「这块能按」的反馈(长按入口靠它才被发现)。
|
||||
onClick = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = when {
|
||||
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
isUser -> MiuixTheme.colorScheme.onPrimary
|
||||
else -> MiuixTheme.colorScheme.onSurfaceContainer
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡里的图片(缩略)。
|
||||
*
|
||||
* 点了就进全屏查看——[onClick] 收到的是**已经替换过 host** 的那一份字符串([ImageHostRebase]:
|
||||
* `data:` 与本地引用原样、只有服务端同源图片地址会被换到当前 baseUrl),
|
||||
* 来源判定交给 `ImageSource.of`,这里不猜。点击区就是这张图本身(`clip` 之后可点,
|
||||
* 涟漪不会溢出圆角);读屏也需要知道点它会发生什么,所以带上 `contentDescription`。
|
||||
*
|
||||
* 高度不设限:图片按比例撑开,气泡那一列本来就是纵向排列。
|
||||
*/
|
||||
@Composable
|
||||
private fun ChatImage(model: String, onClick: (String) -> Unit) {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = "查看图片",
|
||||
modifier = Modifier
|
||||
.widthIn(max = 240.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable { onClick(model) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun plainBubble(text: String) = ParsedBubble(
|
||||
body = text,
|
||||
parts = emptyList(),
|
||||
head = emptyList(),
|
||||
tail = emptyList(),
|
||||
)
|
||||
@@ -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
|
||||
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user