Author SHA1 Message Date
AskaEth 9b8c8ab37d Initial commit 2026-05-23 13:56:52 +08:00
176 changed files with 104 additions and 21150 deletions

No files matched your search

+28 -5
View File
@@ -1,12 +1,35 @@
*.iml
# ---> Android
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
.idea/
.DS_Store
# Log/OS Files
*.log
# Android Studio generated files and folders
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
# 本地设计/协作文档,不进仓库
devdocs/
+73
View File
@@ -0,0 +1,73 @@
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.
+3
View File
@@ -0,0 +1,3 @@
# Cyrene-For-Android
昔涟在安卓设备上的载体。
-78
View File
@@ -1,78 +0,0 @@
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)
}
-10
View File
@@ -1,10 +0,0 @@
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class **$$serializer {
*** descriptor;
}
-keepclasseswithmembers class ** {
kotlinx.serialization.KSerializer serializer(...);
}
-53
View File
@@ -1,53 +0,0 @@
<?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>
</application>
</manifest>
@@ -1,211 +0,0 @@
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.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)
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)
fun createChatSocket(): ChatSocket = ChatSocket(
baseUrlProvider = { authManager.currentBaseUrl },
tokenProvider = authManager::currentToken,
onAuthRecover = authManager::recoverSocketAuth,
onAuthFailed = authManager::onSessionExpired,
)
}
@@ -1,32 +0,0 @@
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() } },
)
}
}
@@ -1,14 +0,0 @@
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)
}
}
@@ -1,72 +0,0 @@
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
}
@@ -1,53 +0,0 @@
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()}"
}
@@ -1,38 +0,0 @@
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)
}
}
@@ -1,229 +0,0 @@
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.flow.StateFlow
import top.yeij.cyrene.domain.chat.DocumentAttachments
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,见 [ShareRules]),
* 另有系统的 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
val extracted = ShareRules.extract(
action = intent.action,
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
stream = intent.streamUri(),
streams = intent.streamUris(),
clipUris = intent.clipUris(),
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
mimeOf = { uri ->
runCatching { context.contentResolver.getType(Uri.parse(uri)) }.getOrNull()
},
)
// 没有任何文本、也没有任何待落地的 URI ⇒ 这条分享里什么都没有(丢件计数在提取期恒为 0;
// 保留原来那条防御性判断,只是把「文件」那一档一并算上)。
if (extracted.isEmpty && extracted.droppedImages == 0 && extracted.droppedDocuments == 0) 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,返回**本端自己的** `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
}
}
private fun Intent.streamUri(): String? =
getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)?.toString()
private fun Intent.streamUris(): List<String> =
getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java).orEmpty().map { it.toString() }
/** `ClipData` 里的 URI(部分 App 只填它);纯文本条目的 URI 为空,天然被过滤掉。 */
private fun Intent.clipUris(): List<String> {
val clip = clipData ?: return emptyList()
return (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri?.toString() }
}
}
@@ -1,254 +0,0 @@
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
}
}
@@ -1,137 +0,0 @@
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"
}
}
@@ -1,132 +0,0 @@
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
}
}
@@ -1,29 +0,0 @@
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
}
@@ -1,53 +0,0 @@
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,
}
}
@@ -1,106 +0,0 @@
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`)。
*
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **整键省略**
* (服务端视同 `"all"`,与加入 `scope` 之前那条帧逐字节相同)。
*/
enum class InterruptMode(val wireScope: String?) {
/**
* 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。
*
* 该模式**不发** `interrupt`;一旦因别的触发点要发,就用省略 `scope` 的旧形状
* (服务端 = `all`:取消在跑回合 + 清空待处理队列)——这是与旧版逐字节兼容的那一档。
*/
AppendQueue(null),
/**
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
*/
Interrupt("current"),
}
/**
* 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 fun String?.toInterruptMode(): InterruptMode = when (this) {
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
else -> InterruptMode.AppendQueue
}
private companion object {
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
}
}
@@ -1,63 +0,0 @@
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")
}
}
@@ -1,95 +0,0 @@
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 `baseUrl + username`;换服务器/账号后快照作废
* @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()
}
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())
}
}
@@ -1,16 +0,0 @@
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)
}
@@ -1,39 +0,0 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
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>>>
/** 人格列表(含 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>>>
}
@@ -1,122 +0,0 @@
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
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。 */
fun <T : Any> create(
baseUrl: String,
service: Class<T>,
tokenProvider: () -> String? = { null },
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
): T {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, 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
}
@@ -1,21 +0,0 @@
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 = "",
)
@@ -1,76 +0,0 @@
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)))
}
@@ -1,23 +0,0 @@
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>>
}
@@ -1,23 +0,0 @@
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>>
}
@@ -1,68 +0,0 @@
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 待发布**,形状依据见 `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>
}
@@ -1,16 +0,0 @@
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>>
}
@@ -1,18 +0,0 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.UserPersonaDto
import retrofit2.Response
import retrofit2.http.GET
/**
* **用户侧**角色目录(api-client §1 行 9)。任意已认证角色可读,无 token 才 `401`。
*
* 与 [AdminApi.personas] 分开是有意的:admin 端点每项多一个 `thinking`,而且整条路径要求
* `viewer` 以上的管理档——拿它当用户侧接口用,普通用户身上会拿到 `403`。
*/
interface PersonaApi {
/** 角色列表;服务端顺序即权威顺序,客户端不重排。 */
@GET("api/v1/personas")
suspend fun personas(): Response<ApiEnvelope<List<UserPersonaDto>>>
}
@@ -1,95 +0,0 @@
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.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.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)返回。
* @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制)
* @param before 上一页最旧消息的 `createdAt`(Unix 毫秒);null = 最新一页
* @param platform 来源平台过滤;null = 全量
*/
@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,
): 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>>
}
@@ -1,33 +0,0 @@
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>>
}
@@ -1,107 +0,0 @@
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,
)
/**
* 人格摘要(用户侧 `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,
)
@@ -1,75 +0,0 @@
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 待发布,形状见 [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(),
)
/**
* `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,
)
@@ -1,83 +0,0 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* 文档 DTO —— 「文件进对话」的**唯一形状落点**(契约 **v0.59 尚未发布**)。
*
* ## 形状从哪来
* 写这份代码时 `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` 只说明注入正文被预算截断,正文照样进得了上下文)。
*
* ## 客户端必须两种都吃的一件事
* 「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` 为 Go 的 `omitempty`
* —— 零值(0 / false / "")时**整键省略**,所以取值一律给默认值,解码不会因缺键失败。
*/
@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 = "",
)
@@ -1,59 +0,0 @@
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,
)
@@ -1,46 +0,0 @@
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 待发布;形状见 [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,
)
@@ -1,29 +0,0 @@
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 = "",
)
@@ -1,26 +0,0 @@
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,
)
@@ -1,146 +0,0 @@
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,
)
@@ -1,31 +0,0 @@
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 = "",
)
@@ -1,133 +0,0 @@
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"
}
@@ -1,52 +0,0 @@
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.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)) }
}
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
}
}
@@ -1,102 +0,0 @@
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))
}
@@ -1,142 +0,0 @@
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"
@@ -1,61 +0,0 @@
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.HistoryMessageDto
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 会话消息分页获取层。服务端按倒序返回,这里统一转成升序的 [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)
companion object {
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 200
}
}
private fun toPagedMessages(
rawNewestFirst: List<HistoryMessageDto>,
pageSize: Int,
): PagedMessages {
val ascending = rawNewestFirst.sortedWith(MessageOrder)
return PagedMessages(
messages = ascending,
hasMore = rawNewestFirst.size >= pageSize,
oldestCreatedAt = ascending.firstOrNull()?.createdAt,
newestCreatedAt = ascending.lastOrNull()?.createdAt,
)
}
@@ -1,22 +0,0 @@
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() }
}
}
@@ -1,58 +0,0 @@
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 }
@@ -1,24 +0,0 @@
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() }
}
}
@@ -1,84 +0,0 @@
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)) }
}
}
@@ -1,112 +0,0 @@
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"
@@ -1,319 +0,0 @@
package top.yeij.cyrene.data.ws
import top.yeij.cyrene.core.auth.AuthRecovery
import top.yeij.cyrene.data.api.ApiClientFactory
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)。
*
* ## 用法
* ```
* val socket = ChatSocket(baseUrlProvider = { cfg.baseUrl }, tokenProvider = { auth.token },
* onAuthFailed = { auth.logout() })
* socket.connect()
* // 收集 socket.events,观察 socket.state
* socket.sendChat(sessionId = "web", text = "你好", deviceId = deviceId)
* socket.close()
* ```
*
* ## 会话归属
* 服务端自 v0.36 起在**每个结构化流帧的帧壳**上带 `sessionId`,本类把它原样透传
* (见 [WsEvent.sessionId]),不补值。服务端广播**不按会话订阅**:同一条连接会收到**所有**
* 会话的帧,因此收集 [events] 的上层**必须**按 [WsEvent.isForSession] 收敛到自己的会话。
*
* ## 心跳与重连
* - 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()
private val _events = MutableSharedFlow<WsEvent>(
replay = 0,
extraBufferCapacity = EVENT_BUFFER,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/**
* 下行事件流。
*
* `replay = 0`:重放旧 delta 会让新订阅者重复渲染,会话状态应由 `history` / REST 重建。
* `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
/** 开始连接(幂等:已在连接 / 重连中时忽略)。 */
fun connect() {
if (wantConnection) return
wantConnection = true
attempt = 0
authRetried = false
sessionJob = ensureScope().launch { runConnectionLoop() }
}
/** 主动断开并停掉重连;之后可再次 [connect]。 */
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`,不落库。
* @return 未连接或写失败返回 `false`(本层不做离线队列,由上层标红重试)。
*/
fun sendChat(
sessionId: String?,
text: String,
deviceId: String?,
clientMsgId: String? = null,
): Boolean = send(
WsOutbound.Chat(
text = text,
sessionId = sessionId,
deviceId = deviceId,
clientMsgId = clientMsgId,
),
)
/**
* 发送 `interrupt`(api-client §3.1;契约 v0.56/A2 起支持 `scope`)。
*
* @param scope 打断范围:`null` = **省略该键**(服务端视同 `"all"`:取消在跑回合 + 清空待处理队列,
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
*
* **今天没有任何调用方**:聊天的「打断」触发点属聊天区那一轮,本 API 先把传输面备好——
* 调用方从 App 偏好(`InterruptMode`)取 `scope` 后传进来即可。
*/
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"
}
}
@@ -1,22 +0,0 @@
package top.yeij.cyrene.data.ws
import kotlinx.serialization.Serializable
/**
* `observability_log` 帧的一条日志(契约 v0.55 §3.12,与 `GET /api/v1/admin/observability/logs`
* 同形,后端生产者 `gateway.AdminLogEntry`):`{time,level,source,msg}`。
*
* - [time] 是 **RFC3339(UTC,秒精度)**——与 `trace.startedAt` / `audit.when` 同源同形状,
* 而**不是** backthink 那种 Unix 毫秒,两者不可共用解析器。
* - [level] ∈ `debug|info|warn|error`,是**开放集合**:本层原样保留,未知等级既不丢弃也不归一。
* - [source] 是 logger 的来源标签,记录没带来源时是空串(不是 null)。
*
* 放在 `data.ws` 而不是 `data.api.dto`:本端**没有**日志 REST 端点,这个类型唯一的来源就是推送帧。
*/
@Serializable
data class ObservabilityLogEntryDto(
val time: String = "",
val level: String = "",
val source: String = "",
val msg: String = "",
)
@@ -1,718 +0,0 @@
package top.yeij.cyrene.data.ws
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
/**
* 下行事件(服务端 → 客户端)。
*
* 帧壳:`{type, turnId?, seq?, sessionId?, data?}`(api-reference §6.2),
* 只有 `type` 必填,`data` 的形状由 `type` 决定。字段名逐字对齐 api-reference §6.4。
*
* ## 会话归属
* 服务端自 **v0.36** 起给**每个结构化流帧的帧壳**都打上 `sessionId` + `turnId` + `streamId`
* (打戳处 `internal/runtime/loop/stream.go`,上帧处 `gateway/ws_hub_turn.go` 的
* `broadcastStream`),`stream_end` / `stream_chunk` / 状态帧同样带 `SessionID` + `TurnID`。
* 本层把帧壳上的 `sessionId` **原样透传**到每个事件类([WsEvent.sessionId]),**不补值**。
*
* 服务端广播**不按会话订阅**(hub 把帧发给每条已认证连接),因此「这一帧属于哪个会话」只能由
* 客户端按 `sessionId` 收敛——见 [isForSession]。该字段可能为 `null`(帧壳省略,或该类型本就
* 无会话归属),此时是「无法判定」而非「不属于本会话」,上层应放行并自行兜底。
*
* ## 未知事件与坏帧
* [parse] 对未知 `type`、非 JSON、结构不匹配的帧一律返回 `null`(安全忽略),绝不抛异常。
*/
sealed class WsEvent {
abstract val type: String
/** 一轮交换的关联 id(`turn-<hex>`);OnStream 结构化流路径不带。 */
open val turnId: String? = null
/** hub 级单调递增序号,供排序关联流式突发。 */
open val seq: Long? = null
/**
* 本帧归属的会话(帧壳上的 `sessionId`,原样透传)。
* `null` = 帧壳没给,**不代表不属于本会话**——见 [isForSession]。
*/
open val sessionId: String? = null
/** 连接首帧:主会话最近 50 条历史(`thinking` 已被服务端剥离)。 */
data class History(
val messages: List<HistoryMessageDto>,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "history"
}
/** 正文段开始。 */
data class MessageStart(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "message.start"
}
/** 正文 token 增量(累加到当前正文段)。 */
data class MessageDelta(
val delta: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "message.delta"
}
/** 正文段结束(不结束整轮,整轮由 [StreamEnd] 收尾)。 */
data class MessageEnd(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "message.end"
}
/** 思维链段开始。 */
data class ThinkingStart(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "thinking.start"
}
/** 思维链 token 增量。 */
data class ThinkingDelta(
val delta: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "thinking.delta"
}
/** 思维链段结束。 */
data class ThinkingEnd(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "thinking.end"
}
/** 工具调用中间态开始。 */
data class ToolCallStart(
val id: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_call.start"
}
/** 工具调用参数增量(如 `input_json_delta`)。 */
data class ToolCallDelta(
val id: String,
val index: Int,
val delta: String,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_call.delta"
}
/** 工具调用中间态结束。 */
data class ToolCallEnd(
val id: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_call.end"
}
/**
* 工具**进入执行**(后端契约 v0.50 `tool_execution` 三事件之一)。
*
* 这三帧**不属于模型流**(`ws_bridge.go:143`):`data` 里既没有 `index` 也没有 `streamId`,
* 关联键是 provider 的 tool call id([id],与同轮 `tool_call.start/delta/end` 同源;
* provider 没给 id 时该键整个省略,即 `null`)。[phase] 是细粒度执行阶段,取值与流程图
* tool 节点 status 同一套枚举(`running` / `done` / `error`),**不与 `delta` 混用**。
*/
data class ToolExecutionStart(
val tool: String,
val phase: String,
val id: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_execution.start"
}
/**
* 工具**运行中**的进度 / 局部输出,一次执行可多帧(契约 v0.50)。
*
* 诚实边界:wire 映射已实现,但后端工具分发器**暂无中途进度上报源**,loop 今天不产出该帧
* (`start` / `end` 有真实生产者)。此处按契约完整解码,生产者落地后无需再改客户端。
*/
data class ToolExecutionUpdate(
val tool: String,
val phase: String,
val delta: String = "",
val id: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_execution.update"
}
/** 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。 */
data class ToolExecutionEnd(
val tool: String,
val phase: String,
val id: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "tool_execution.end"
}
/** 向后兼容整段文本(仅非流式回退路径;流式路径不 emit)。 */
data class StreamChunk(
val delta: String,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "stream_chunk"
}
/**
* 一轮结束。`stopReason` 取值未穷举(`stop` / `idle` / `error` / …),用字符串承载;
* `error` 形状未定义(字符串或对象都可能),原样以 [JsonElement] 暴露。
*/
data class StreamEnd(
val stopReason: String,
val steps: Int? = null,
val error: JsonElement? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "stream_end"
}
/** 会话进入忙碌态。`mode` ∈ `idle` / `thinking` / `executing` / `subagent`。 */
data class SessionBusy(
override val sessionId: String,
val mode: String,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_busy"
}
/** 忙时新消息已入队(「收到,稍后回」)。 */
data class PendingAck(
override val sessionId: String,
val mode: String,
val queueDepth: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "pending_ack"
}
/** 忙完开始消费下一条待处理消息。 */
data class PendingDrain(
override val sessionId: String,
val mode: String,
val queueDepth: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "pending_drain"
}
/** 会话回到空闲、队列清空。 */
data class SessionIdle(
override val sessionId: String,
val mode: String,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_idle"
}
/** 用户打断:当前任务取消、pending 清空,`dropped` 为丢弃条数。 */
data class SessionInterrupted(
override val sessionId: String,
val mode: String,
val dropped: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_interrupted"
}
/** 单会话 pending 达上限(`max`,默认 5),拒绝入队。 */
data class PendingFull(
override val sessionId: String,
val mode: String,
val max: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "pending_full"
}
/**
* `clientMsgId` 重复到达的明确回执(契约 v0.56 §3.13,纯新增帧)。
*
* 语义 = **这条消息早已被受理过**:服务端**不落库、不起回合、不发任何内容帧**
* (它由编排器的状态通道下发,与 `session_busy` 同一出口,因此帧壳带 `sessionId`、
* 不带 `turnId` / `seq`)。[messageId] 是**既有那条用户消息的 id**,
* 客户端据此把本地乐观气泡对齐到服务端真相。
*
* 广播是发给**所有** owner/admin 连接的(hub 无按会话订阅),没发过该
* [clientMsgId] 的客户端按值自行忽略即可。
*/
data class ChatDuplicate(
override val sessionId: String,
val clientMsgId: String,
val messageId: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "chat_duplicate"
}
/**
* 已读游标推进(D4,契约 v0.37,api-client §3.9):
* `{type:"session_read", sessionId, data:{readAt}}`——**载荷里没有会话 id**,
* 会话归属**只**来自帧壳(服务端 `POST /sessions/{id}/read` 成功后广播)。
*
* 它是那 7 个「不含内容的状态机信号」之一(发给**所有**已认证连接,含 `viewer`),
* 与 `message_persisted` 同一条全连接广播路径,故同样要按 [isForSession] 收敛。
* [readAt] 是**推进后**的游标(毫秒,与该会话消息的 `createdAt` 同单位);
* data 缺席时回落 `0`——游标实际不可能为 0,于是「坏帧」天然退化成**什么也不改**
* (见 `SessionItem.withReadCursor`:只有 `lastMessageAt <= readAt` 才清零)。
*/
data class SessionRead(
override val sessionId: String,
val readAt: Long,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_read"
}
/** 主会话(`web` / `web:<uuid>`)消息落库后广播。 */
data class MessagePersisted(
val message: HistoryMessageDto?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "message_persisted"
}
/** 隔离会话(`sess-xxx`,群聊 / 陌生人)消息落库后广播,供 IM 适配器日志页。 */
data class ChannelPersisted(
val message: HistoryMessageDto?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "channel_persisted"
}
/**
* 指标快照(契约 v0.55 §3.12「可观测实时推送」)。
*
* 载荷就是 `AdminMetrics` **本体、扁平**,与 `GET /admin/observability/metrics` 同形,
* 因此消费方应当**整份替换**而不是累加:服务端只在快照与「上次发出的」不同时才发帧
* (序列化指纹比较,键排序确定)。
*
* [metrics] 为 `null` = 帧里没有可用的载荷对象。这时**必须整帧跳过**:
* 回落成默认值会得到一份「全零快照」,把界面上的真实指标擦成 0。
*
* 帧壳**不带** `sessionId` / `turnId` / `seq`——它不属任何会话(订阅只跟凭据绑定)。
*/
data class ObservabilityMetrics(
val metrics: AdminMetricsDto?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "observability_metrics"
}
/**
* 新完成的 trace span(契约 v0.55 §3.12)。
*
* 载荷 `{spans:[…]}` **最新在前**,与 `GET /admin/observability/trace` 同形,
* 单帧上限 100 条;触发是「订阅之后**新完成**的 span」(订阅锚点 = 当时的 trace 写入序号),
* 所以它是**增量**——历史由页面那次 REST 引导读取提供,这里只负责往后接。
*/
data class ObservabilityTrace(
val spans: List<AdminTraceSpanDto>,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "observability_trace"
}
/**
* 新增日志(契约 v0.55 §3.12)。
*
* 载荷 `{entries:[…]}` **最新在前**(与 `GET /admin/observability/logs` 同形),
* 单帧上限 200 条;触发是「订阅之后**新增**的日志」(订阅锚点 = 当时最新的条目时间),
* 所以它同样是**增量**。服务端的 `?level=` 分级过滤对推送不适用(帧是广播的,hub 不按连接分级),
* 分级选择由客户端本地做。
*/
data class ObservabilityLog(
val entries: List<ObservabilityLogEntryDto>,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "observability_log"
}
/** 语音合成回复(定向回请求连接)。 */
data class VoiceOutput(
val audio: String?,
val format: String?,
val text: String?,
val reply: String?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "voice_output"
}
/** 自主主动推送(仅 owner/admin 连接收到)。 */
data class ProactiveMessage(
val platform: String?,
val channelId: String?,
val channelType: String?,
val content: String,
val contentType: String?,
val sender: String?,
val timestamp: Long?,
val messageId: String?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "proactive_message"
}
/** 服务端错误(非法 JSON / 未知类型 / 语音·子代理·任务错误等)。 */
data class Error(
val message: String,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "error"
}
companion object {
/**
* 解析一帧文本。**永不抛异常**:非 JSON、缺 `type`、未知 `type`、`data` 结构不匹配
* 都返回 `null`,由调用方安全忽略。
*/
fun parse(raw: String): WsEvent? {
val root = runCatching { ApiClientFactory.json.parseToJsonElement(raw) }
.getOrNull() as? JsonObject ?: return null
val type = root["type"].asStringOrNull() ?: return null
val turnId = root["turnId"].asStringOrNull()
val seq = root["seq"].asStringOrNull()?.toLongOrNull()
val sessionId = root["sessionId"].asStringOrNull()
val data = root["data"]?.takeIf { it !is JsonNull }
return runCatching { dispatch(type, turnId, seq, sessionId, data) }.getOrNull()
}
private fun dispatch(
type: String,
turnId: String?,
seq: Long?,
sessionId: String?,
data: JsonElement?,
): WsEvent? = dispatchStreaming(type, turnId, seq, sessionId, data)
?: dispatchObservability(type, turnId, seq, sessionId, data)
?: dispatchSession(type, turnId, seq, sessionId, data)
/**
* 可观测实时推送三帧(契约 v0.55 §3.12)。
*
* `data` **必须是 JSON 对象**才认:这三帧的载荷是它们的全部内容,缺席时没有任何可用的东西。
* 尤其 metrics——若回落成默认值,消费方会拿一份全零快照覆盖界面上的真实指标。返回 `null`
* 让整帧按坏帧被安全忽略(与未知 `type` 同一条出口,绝不抛)。
*/
private fun dispatchObservability(
type: String,
turnId: String?,
seq: Long?,
sessionId: String?,
data: JsonElement?,
): WsEvent? {
val payload = data as? JsonObject ?: return null
return when (type) {
"observability_metrics" -> ObservabilityMetrics(
decode<AdminMetricsDto>(payload), turnId, seq, sessionId,
)
"observability_trace" -> ObservabilityTrace(
decode<TracePushData>(payload).spans, turnId, seq, sessionId,
)
"observability_log" -> ObservabilityLog(
decode<LogPushData>(payload).entries, turnId, seq, sessionId,
)
else -> null
}
}
private fun dispatchStreaming(
type: String,
turnId: String?,
seq: Long?,
sessionId: String?,
data: JsonElement?,
): WsEvent? = when (type) {
"history" -> History(decode<HistoryData>(data).messages, turnId, seq, sessionId)
"message.start" -> MessageStart(decode<IndexData>(data).index, turnId, seq, sessionId)
"message.delta" -> decode<DeltaIndexData>(data)
.let { MessageDelta(it.delta, it.index, turnId, seq, sessionId) }
"message.end" -> MessageEnd(decode<IndexData>(data).index, turnId, seq, sessionId)
"thinking.start" -> ThinkingStart(decode<IndexData>(data).index, turnId, seq, sessionId)
"thinking.delta" -> decode<DeltaIndexData>(data)
.let { ThinkingDelta(it.delta, it.index, turnId, seq, sessionId) }
"thinking.end" -> ThinkingEnd(decode<IndexData>(data).index, turnId, seq, sessionId)
"tool_call.start" -> decode<ToolCallIdData>(data)
.let { ToolCallStart(it.id, it.index, turnId, seq, sessionId) }
"tool_call.delta" -> decode<ToolCallData>(data)
.let { ToolCallDelta(it.id, it.index, it.delta, turnId, seq, sessionId) }
"tool_call.end" -> decode<ToolCallIdData>(data)
.let { ToolCallEnd(it.id, it.index, turnId, seq, sessionId) }
"tool_execution.start" -> decode<ToolExecutionData>(data)
.let { ToolExecutionStart(it.tool, it.phase, it.id, turnId, seq, sessionId) }
"tool_execution.update" -> decode<ToolExecutionData>(data)
.let { ToolExecutionUpdate(it.tool, it.phase, it.delta, it.id, turnId, seq, sessionId) }
"tool_execution.end" -> decode<ToolExecutionData>(data)
.let { ToolExecutionEnd(it.tool, it.phase, it.id, turnId, seq, sessionId) }
"stream_chunk" -> StreamChunk(decode<StreamChunkData>(data).delta, turnId, seq, sessionId)
"stream_end" -> decode<StreamEndData>(data)
.let { StreamEnd(it.stopReason, it.steps, it.error, turnId, seq, sessionId) }
else -> null
}
private fun dispatchSession(
type: String,
turnId: String?,
seq: Long?,
sessionId: String?,
data: JsonElement?,
): WsEvent? = when (type) {
"session_busy" -> decode<SessionModeData>(data)
.let { SessionBusy(resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq) }
"pending_ack" -> decode<PendingData>(data)
.let { PendingAck(resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq) }
"pending_drain" -> decode<PendingData>(data)
.let { PendingDrain(resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq) }
"session_idle" -> decode<SessionModeData>(data)
.let { SessionIdle(resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq) }
"session_interrupted" -> decode<InterruptedData>(data)
.let { SessionInterrupted(resolveSessionId(sessionId, it.sessionId), it.mode, it.dropped, turnId, seq) }
"pending_full" -> decode<PendingFullData>(data)
.let { PendingFull(resolveSessionId(sessionId, it.sessionId), it.mode, it.max, turnId, seq) }
// 幂等回执(v0.56 §3.13):帧壳带 sessionId,data 内也带一份(两者取有值者)。
"chat_duplicate" -> decode<ChatDuplicateData>(data)
.let {
ChatDuplicate(
resolveSessionId(sessionId, it.sessionId),
it.clientMsgId,
it.messageId,
turnId,
seq,
)
}
// 已读游标(D4 §3.9):会话归属只在帧壳上(data 只有 readAt)。
"session_read" -> decode<SessionReadData>(data)
.let { SessionRead(resolveSessionId(sessionId, ""), it.readAt, turnId, seq) }
"message_persisted" -> decode<PersistedData>(data)
.let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
"channel_persisted" -> decode<PersistedData>(data)
.let { ChannelPersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
"voice_output" -> decode<VoiceOutputData>(data)
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
"proactive_message" -> decode<ProactiveData>(data).let {
ProactiveMessage(
it.platform, it.channelId, it.channelType, it.content, it.contentType,
it.sender, it.timestamp, it.messageId, turnId, seq, sessionId,
)
}
"error" -> Error(decode<ErrorData>(data).message, turnId, seq, sessionId)
else -> null
}
private fun resolveSessionId(shell: String?, fromData: String): String =
fromData.ifBlank { shell ?: "" }
private inline fun <reified T> decode(data: JsonElement?): T =
ApiClientFactory.json.decodeFromJsonElement<T>(data ?: JsonObject(emptyMap()))
}
}
/**
* 这一帧是否属于 [sessionId] 这个会话(纯函数)。
*
* 服务端广播不按会话订阅,**每条**连接都会收到**每个**会话的帧,所以调用方必须先过这一闸,
* 否则别的会话的 `message.delta` 会被自己的流式状态机吃掉。
*
* 判定原则是**只排除「明确属于别的会话」的帧**:
* - 帧壳上的 `sessionId` 为空(`null` / 空白)→ **放行**。空值是「无法判定」而不是「不属于本会话」,
* 丢掉会让旧服务端(或不带该字段的帧类型)整类事件消失——这与
* [top.yeij.cyrene.data.repository.historyForSession] 对空 `sessionId` 的处理一致。
* - 有值且相等 → 放行;有值且不等 → 丢弃。
*
* 不修改入参,无副作用。
*/
fun WsEvent.isForSession(sessionId: String): Boolean {
val frameSession = this.sessionId
return frameSession.isNullOrBlank() || frameSession == sessionId
}
private fun JsonElement?.asStringOrNull(): String? {
val primitive = this as? JsonPrimitive ?: return null
if (primitive is JsonNull) return null
return primitive.content
}
@Serializable
private data class IndexData(val index: Int = 0)
@Serializable
private data class DeltaIndexData(val delta: String = "", val index: Int = 0)
@Serializable
private data class ToolCallIdData(val id: String = "", val index: Int = 0)
@Serializable
private data class ToolCallData(val id: String = "", val index: Int = 0, val delta: String = "")
/**
* `tool_execution.*` 的载荷(后端 `ws_bridge.go:toolExecutionData`):`tool` + `phase` 恒在,
* `id` 在 provider 未给时**整个键省略**,`delta` 只在 `update` 且非空时出现。
* 刻意**不复用** [ToolCallData]:那是 `tool_call.*` 的形状,真的有 `index`;这三帧没有,
* 也没有 `streamId`(它们不属于某一次模型流)。
*/
@Serializable
private data class ToolExecutionData(
val tool: String = "",
val phase: String = "",
val id: String? = null,
val delta: String = "",
)
@Serializable
private data class HistoryData(val messages: List<HistoryMessageDto> = emptyList())
@Serializable
private data class StreamChunkData(val delta: String = "")
@Serializable
private data class StreamEndData(
val stopReason: String = "",
val steps: Int? = null,
val error: JsonElement? = null,
)
@Serializable
private data class SessionModeData(val sessionId: String = "", val mode: String = "")
@Serializable
private data class PendingData(val sessionId: String = "", val mode: String = "", val queueDepth: Int = 0)
@Serializable
private data class InterruptedData(val sessionId: String = "", val mode: String = "", val dropped: Int = 0)
@Serializable
private data class PendingFullData(val sessionId: String = "", val mode: String = "", val max: Int = 0)
/** `chat_duplicate` 载荷(v0.56 §3.13):`{sessionId, clientMsgId, messageId}`。 */
@Serializable
private data class ChatDuplicateData(
val sessionId: String = "",
val clientMsgId: String = "",
val messageId: String? = null,
)
/** `session_read` 载荷(api-client §3.9):`{readAt}`——毫秒游标,**不带**会话 id。 */
@Serializable
private data class SessionReadData(val readAt: Long = 0L)
@Serializable
private data class PersistedData(val message: HistoryMessageDto? = null)
/** `observability_trace` 载荷(后端 `gateway.AdminTracePush`):`{spans:[…]}`,最新在前。 */
@Serializable
private data class TracePushData(val spans: List<AdminTraceSpanDto> = emptyList())
/** `observability_log` 载荷(后端 `gateway.AdminLogPush`):`{entries:[…]}`,最新在前。 */
@Serializable
private data class LogPushData(val entries: List<ObservabilityLogEntryDto> = emptyList())
@Serializable
private data class VoiceOutputData(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val reply: String? = null,
)
@Serializable
private data class ProactiveData(
val platform: String? = null,
val channelId: String? = null,
val channelType: String? = null,
val content: String = "",
val contentType: String? = null,
val sender: String? = null,
val timestamp: Long? = null,
val messageId: String? = null,
)
@Serializable
private data class ErrorData(val message: String = "")
@@ -1,135 +0,0 @@
package top.yeij.cyrene.data.ws
import kotlinx.serialization.json.JsonElement
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?}`;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,
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) }
}
}
/** `interrupt`:`{sessionId, scope?}`。命中空闲会话为 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,
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) }
}
}
/** `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) }
}
}
@@ -1,25 +0,0 @@
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 或地址),不会自动重连。 */
Offline,
}
@@ -1,74 +0,0 @@
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 只读监控档),
* 但它**一帧推送都收不到**(服务端不推,客户端也就不该开连接去等)。
*
* 客户端按同一档自行降级:不够档就**不开连接、不合并帧**,而不是等「怎么一直没帧」再猜。
*/
fun canReceiveObservabilityFrames(role: AdminRole): Boolean =
role.rank >= AdminTier.OwnerAdmin.rank
/** 当前角色可用的页,顺序与 [AdminPage] 声明一致。 */
fun availablePages(role: AdminRole): List<AdminPage> = AdminPage.entries.filter { canAccess(role, it) }
@@ -1,26 +0,0 @@
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("操作失败,请稍后重试")
}
}
@@ -1,137 +0,0 @@
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)
}
/**
* 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)
}
}
@@ -1,156 +0,0 @@
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() }
}
@@ -1,72 +0,0 @@
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
/** 服务端 `image` 字符串长度上限(= base64 膨胀 4/3 + 前缀余量)。 */
const val SERVER_MAX_CHARS =
((SERVER_MAX_DECODED_BYTES + 2) / 3) * 4 + PREFIX_ALLOWANCE_CHARS
/** 本端目标:重编码后的**字节**上限,远低于服务端硬顶(见类注释)。 */
const val MAX_DECODED_BYTES = 1 * 1024 * 1024
/**
* 最长边的目标像素。1280 与 provider 自己入模型前的缩放口径(约 1300×1300)同量级,
* 所以再放大不会有任何可见收益——省的是库与历史的字节,不是 token。
*/
const val MAX_DIMENSION_PX = 1280
/** [byteCount] 字节 base64 编码后的字符数(每 3 字节 → 4 字符,末组补满)。 */
fun base64Chars(byteCount: Int): Int = ((byteCount + 2) / 3) * 4
/** 字符串长度是否在服务端上限内(入口层同一判据:`<=` 才算通过)。 */
fun withinServerLimit(dataUriChars: Int): Boolean = dataUriChars <= SERVER_MAX_CHARS
/** 编码后字节数是否已落到本端目标内。 */
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"
}
@@ -1,51 +0,0 @@
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
}
@@ -1,64 +0,0 @@
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
}
}
@@ -1,189 +0,0 @@
package top.yeij.cyrene.domain.chat
import java.io.ByteArrayOutputStream
import java.io.InputStream
/**
* 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImage` 平行的概念)。
*
* 与 [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()
}
@@ -1,113 +0,0 @@
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,
) {
/** 芯片副行:`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),
)
/** 一份**待发**附件 → 芯片模型:还没有服务端结论,所以状态与字数为空。 */
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,
)
/** 扩展名大写;没有扩展名或扩展名不像扩展名时回「文件」(不猜类型)。 */
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
}
@@ -1,177 +0,0 @@
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,
}
/**
* 「该不该走文件」的判定与 `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))
/**
* 由**已上传**的服务端存储名构造 `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) }
}
}
@@ -1,72 +0,0 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
/**
* 文档操作失败(上传 / 取值)。契约 **v0.59 待发布**,错误码依据见
* `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("文档操作失败,请稍后重试")
}
}
@@ -1,31 +0,0 @@
package top.yeij.cyrene.domain.chat
/**
* 文档抽取状态(契约 **v0.59 待发布**,形状依据见 `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
}
@@ -1,63 +0,0 @@
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 }
@@ -1,409 +0,0 @@
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) }
}
@@ -1,45 +0,0 @@
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()
}
@@ -1,224 +0,0 @@
package top.yeij.cyrene.domain.share
/**
* 从别的 App 分享进来、已经可以落地的内容。
*
* [images] / [documents] 里的 URI 在**提取阶段**还是对方给的原始 URI;由 `app/ShareIntake.kt`
* 拷进缓存后换成**本端自己**的 `file://`:外部 `content://` 的读授权只在这个 intent 的接收方
* (Activity / 任务)存活期间有效,跨不过「未登录 → 登录」,所以必须在收到的那一刻就落盘。
*
* 丢件计数按**两条路分开记**(图片 / 文档),因为「本端的接收上限」与「对方给的东西读不到」
* 是两句不同的话,而图片与文档的上限也不是同一个数。分享进来的内容**不许静默少发**。
*/
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,
) {
/** 没能落地的总条数。 */
val droppedImages: Int get() = truncatedImages + failedImages
/** 没能落地的文件总条数。 */
val droppedDocuments: Int get() = truncatedDocuments + failedDocuments
/** 没有任何可发送的内容(此时只剩下丢件计数需要告知)。 */
val isEmpty: Boolean get() = text.isBlank() && images.isEmpty() && documents.isEmpty()
}
/**
* 一份分享进来的**非图片**文件。
*
* [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 判。
*
* ## 为什么正文一个字都不加
* **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `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
/**
* 从分享 Intent 的原始字段提取内容。
*
* URI 的取值顺序是 `EXTRA_STREAM` → `ClipData`:前者是契约路径,后者是兜底——确实有
* App(尤其旧版本)只填 `ClipData` 而不填 `EXTRA_STREAM`,只认前者会让分享静默落空。
* 两者都有时**只取前者**,不会把同一张图数两遍。
*
* @param action `Intent.action`;只认 [ACTION_SEND] / [ACTION_SEND_MULTIPLE]
* @param text `EXTRA_TEXT`——按 `CharSequence` 收,因为它常常是 `Spanned`(带样式的
* HTML 富文本),读成 `String` 会直接拿到 `null`
* @param stream `EXTRA_STREAM`([ACTION_SEND] 的单条 URI 字符串)
* @param streams `EXTRA_STREAM`([ACTION_SEND_MULTIPLE] 的 URI 列表)
* @param clipUris `ClipData` 各条目的 URI;文本类条目没有 URI,会被自动忽略
* @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是
* **纯函数**,所以类型判定由调用方注入;默认「拿不到类型」⇒ 按文档路走
* (见 [classifySharedStream],默认值就是「不猜」)。
*/
fun extract(
action: String?,
text: CharSequence? = null,
stream: String? = null,
streams: List<String>? = null,
clipUris: List<String> = emptyList(),
mimeOf: (String) -> String? = { null },
): SharedContent {
val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE
if (!recognized) return SharedContent()
val body = text?.toString().orEmpty().trim()
val primary = when (action) {
ACTION_SEND -> listOfNotNull(stream)
else -> streams.orEmpty()
}
// 空串条目(有的 App 会塞占位)与重复条目都要去掉:不然「4 张图」里可能只剩 2 张真图,
// 而截断计数还会把占位算成一张。
val unique = primary.ifEmpty { clipUris }
.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
}
}
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),
)
}
/**
* 落盘文件名:**只由本端生成**的 [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"
}
}
@@ -1,72 +0,0 @@
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 "未送达"}"
@@ -1,306 +0,0 @@
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()
@@ -1,154 +0,0 @@
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)
}
}
@@ -1,59 +0,0 @@
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,
)
}
}
}
}
}
}
@@ -1,95 +0,0 @@
package top.yeij.cyrene.ui.admin
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.ws.ObservabilityLogEntryDto
/**
* 可观测实时推送帧的**合并**与**本地过滤**(纯函数,契约 v0.55 §3.12)。
*
* 两个来源各自负责一半,谁都不替代谁:
* - **REST 引导读取**(进页面 / 手动刷新 / 断线重连)给一份**全量**快照(最新在前);
* - **推送帧**是**增量**:`observability_trace` 只带订阅之后新完成的 span,
* `observability_log` 只带订阅之后新增的日志(订阅时以「当前最新」为锚,服务端**不会**
* 把环形缓冲里的历史灌成巨型帧);`observability_metrics` 是**整份快照**,见
* `AdminViewModel`(它是替换而不是合并,所以不在这里)。
*
* 因此这里的合并语义是「**往前接**」:新到的在前,与已有按业务键去重,再按窗口截断。
* 两端本来就是最新在前,所以**不做排序**——排序需要解析时间戳(日志是 RFC3339 秒精度、
* span 是纳秒精度、还可能带不同偏移),一旦解析口径与后端不一致,反而会把正确的顺序排乱。
*/
/** trace 保留窗口,与 REST 引导读取的 `limit`(`AdminRepository.DEFAULT_TRACE_LIMIT`)一致。 */
internal const val TRACE_WINDOW = 100
/** 日志保留窗口,与契约 §3.12 的单帧上限一致(更旧的条目本就落在保留窗口之外)。 */
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 }
/**
* 合并日志帧:新条目在前,按业务键(时间 + 等级 + 来源 + 消息)去重,保留最近 [window] 条。
*
* 去重键取整条内容,是因为日志**没有 id**(REST `logs` 的条目形状就是这四个字段)。
* 代价如实说:同一秒内、来源与消息完全相同的两条日志会被收成一条——与 Web 面板
* (`observabilityStream.ts` 按业务键合并)同一口径。
*/
internal fun mergeLogEntries(
existing: List<ObservabilityLogEntryDto>,
incoming: List<ObservabilityLogEntryDto>,
window: Int = LOG_WINDOW,
): List<ObservabilityLogEntryDto> = mergeNewestFirst(existing, incoming, window, ::logEntryKey)
/** 日志条目的业务键;合并去重与列表 `key` 都用它,两处必须一致。 */
internal fun logEntryKey(entry: ObservabilityLogEntryDto): String =
"${entry.time}|${entry.level}|${entry.source}|${entry.msg}"
/**
* 按**最低等级**过滤日志,纯客户端行为:服务端的 `?level=` 对推送不适用
* (帧广播给所有 owner/admin 连接,订阅只决定推送循环跑不跑)。
*
* [minLevel] 为空 / 未知 → 不过滤。**等级未知的条目一律保留**:`level` 是开放集合,
* 判不出高低的条目不能因为「可能低于阈值」被静默藏掉(与原样保留未知值的约定一致)。
*/
internal fun filterLogEntries(
entries: List<ObservabilityLogEntryDto>,
minLevel: String?,
): List<ObservabilityLogEntryDto> {
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
}
/**
* 「最新在前」列表的合并:先把 [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))
}
@@ -1,342 +0,0 @@
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.AdminMetricsDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.ws.ObservabilityLogEntryDto
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.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**(metrics / trace);之后 `observability_*` 三帧(仅 owner/admin 收得到)
* 由 [AdminViewModel] 合并进同一份状态。日志只有推送这一个来源,因此**一帧都没到过时整段不渲染**
* (详见 `AdminOverviewData.logs`)。
*/
@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()) ->
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(failure))
}
}
}
item(key = "__paths__") {
// metrics 缺席时这里整段不渲染:上面那张卡已经说明了「指标不可用」,
// 再给一个空列表就是把失败画成「没有路径」。
data.metrics?.let { metrics ->
PathStatsSection(perPath = metrics.perPath, toggled = toggledGroups)
}
}
item(key = "__trace_title__") { AdminSectionTitle("最近 trace") }
when {
// trace 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。
failure != null -> item(key = "__trace_error__") {
AdminTextCard("trace 不可用", failureMessage(failure))
}
data.trace.isEmpty() -> item(key = "__trace_empty__") {
AdminInlineHint("暂无 trace span")
}
else -> items(items = data.trace, key = { it.id }) { span ->
TraceSpanCard(span)
}
}
// 日志:**只有推送一个来源**(本端没有 logs REST 端点)。
// 一帧都没到过就整段不渲染——这里既没有 REST 引导,也没有「确实没有日志」的依据,
// 写「暂无日志」等于把「没有推送」说成「没有数据」。
if (data.logs.isNotEmpty()) {
item(key = "__logs_title__") { AdminSectionTitle("最近日志") }
item(key = "__logs_level__") { LogLevelTabs(logLevel) { logLevel = it } }
if (visibleLogs.isEmpty()) {
item(key = "__logs_empty__") { AdminInlineHint("当前等级下没有日志") }
} else {
items(items = visibleLogs, key = { logEntryKey(it) }) { entry ->
LogEntryCard(entry)
}
}
}
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: ObservabilityLogEntryDto) {
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: ObservabilityLogEntryDto): 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 ?: "请求失败,请重试"
@@ -1,117 +0,0 @@
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(" · ")
}
@@ -1,273 +0,0 @@
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.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.ObservabilityLogEntryDto
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.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 两次请求合成一份状态(`trace` 作为指标卡片下方列表)。 */
data class AdminOverviewData(
val metrics: AdminMetricsDto? = null,
val trace: List<AdminTraceSpanDto> = emptyList(),
/**
* 日志:本端**没有** logs REST 端点(`data/api` 不在本模块改动面内),
* 所以它**只**来自 `observability_log` 帧累积;一帧都没到过时是空列表。
* UI 据此**整段不渲染**——不写「暂无日志」,那等于把「没有推送」说成「确实没有日志」。
*/
val logs: List<ObservabilityLogEntryDto> = emptyList(),
)
/**
* 管理只读监控的四页取数(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` 既不开连接也不合帧,
* 继续走它本就有权读的那两个 REST 端点。
*
* ## 为什么本类自己开一条 WS 连接
* `observability_*` 三帧在 WS 上,而 `AppContainer` 只提供 `createChatSocket()`
* (**每次调用新建**,没有进程级共享连接),`ChatSocket` / 聊天侧却都在别的改动范围里。
* 要在这里收帧,唯一不改别人的办法就是自持一条连接——与 `SessionsViewModel` 同一做法,
* 也**同一笔已知技术债**(「每 VM 一条 socket」,收敛方向是「一条应用级共享 socket +
* 按会话路由」,见 `devdocs/05`;那个重构不在本模块内)。
*
* 生命周期:登录且角色够档时连接、登出(或角色降档)断开、VM 销毁断开([onCleared])。
* 本类只关心这三个帧类型,收不到也不会让功能失效——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()
/** 本 VM 自持的那条推送连接;见类注释。 */
private val socket: ChatSocket = container.createChatSocket()
init {
viewModelScope.launch {
container.authManager.state.collect { auth ->
when (auth) {
SessionState.Unknown -> Unit
// 登出:断开连接并丢掉**帧来源**的状态(日志)。它会跨账号残留是因为
// 它没有 REST 兜底——metrics / trace 每次进页面都会被重拉的快照整体替换。
SessionState.LoggedOut -> {
socket.close()
_overview.update { state ->
state.copy(data = state.data?.copy(logs = emptyList()))
}
}
// 连接只在「登录 + 角色够档」时建立:viewer 收不到这三帧(服务端 fail-closed),
// 也就没有必要开一条连接去等。角色可能随重登变化,所以这里两个分支都要显式写。
is SessionState.LoggedIn -> if (canReceiveObservabilityFrames(AdminRole.of(auth.role))) {
socket.connect()
} else {
socket.close()
}
}
}
}
viewModelScope.launch { socket.events.collect(::onEvent) }
viewModelScope.launch { reloadOnConnect() }
}
override fun onCleared() {
socket.close()
super.onCleared()
}
/** 进入页面时调用;每次进入都重新取数(监控数据要新鲜)。 */
fun load(page: AdminPage, role: AdminRole) {
if (!canAccess(role, page)) {
deny(page)
return
}
viewModelScope.launch {
when (page) {
AdminPage.Overview -> loadOverview()
AdminPage.Personas -> loadList(_personas) { container.adminRepository.personas() }
AdminPage.Memories -> loadList(_memories) { container.adminRepository.memories() }
AdminPage.Backthink -> loadList(_backthink) { container.adminRepository.backthink() }
}
}
}
/**
* 概览页两个请求并发;任一 403 即整页无权限,其它失败则保留已到的部分。
*
* 帧累积的 [AdminOverviewData.logs] **不参与本次替换**:日志没有 REST 来源,
* 重拉清掉它就等于让它永久消失(帧只送增量,不会把历史再灌一遍)。
* 用 `update` 原子地取「此刻的日志」,是为了不把**请求在飞期间**到达的日志帧抹掉。
*
* metrics / trace 则反过来**以本次快照为准**:帧送的是增量,而 REST 是全量(含所有新 span),
* 竞态下丢掉的那一拍增量会被下一次条目/指标变化补回来,不值得为它引入版本号。
*/
private suspend fun loadOverview() {
_overview.update { it.copy(loading = true, failure = null) }
val repository = container.adminRepository
val (metrics, trace) = coroutineScope {
val metricsCall = async { repository.metrics() }
val traceCall = async { repository.trace() }
metricsCall.await() to traceCall.await()
}
val failures = listOfNotNull(metrics.exceptionOrNull(), trace.exceptionOrNull())
.map { it.toAdminFailure() }
val freshMetrics = metrics.getOrNull()
val freshTrace = trace.getOrNull().orEmpty()
val failure = failures.firstOrNull { it is AdminFailure.Forbidden } ?: failures.firstOrNull()
_overview.update { state ->
AdminUiState(
data = AdminOverviewData(
metrics = freshMetrics,
trace = freshTrace,
logs = state.data?.logs.orEmpty(),
),
failure = failure,
)
}
}
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)))
}
}
/** 日志帧是**增量**:与已有条目按业务键去重合并(见 [mergeLogEntries])。 */
private fun onLogFrame(event: WsEvent.ObservabilityLog) {
if (event.entries.isEmpty()) return
_overview.update { state ->
val data = state.data ?: return@update state
state.copy(data = data.copy(logs = mergeLogEntries(data.logs, 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() {
if (!canReceiveObservabilityFrames(currentRole())) return
if (_overview.value.data == null) return
viewModelScope.launch { loadOverview() }
}
}
@@ -1,104 +0,0 @@
package top.yeij.cyrene.ui.chat
import androidx.activity.compose.BackHandler
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.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import top.yeij.cyrene.app.AppContainer
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
/**
* 聊天页 + 侧边栏(会话切换)+ 设置页 + 管理员监控。
* 返回键逐层回退:管理/设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。
*/
@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) }
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)
},
onLogout = onLogout,
)
},
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
},
)
},
)
}
}
@@ -1,463 +0,0 @@
package top.yeij.cyrene.ui.chat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import coil3.compose.AsyncImage
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.app.ShareIntake
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.basic.Sidebar
import top.yukonga.miuix.kmp.icon.extended.File
import top.yukonga.miuix.kmp.icon.extended.Image
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 判定「贴着底部」的容差(px)。 */
private const val BOTTOM_TOLERANCE = 64
/** 分享落地前等 WS 连接就绪的上限(毫秒):超时也照样发,让失败落在看得见的地方。 */
private const val SHARE_CONNECT_TIMEOUT_MILLIS = 3_000L
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp
/**
* 选文件时交给系统选择器的 MIME 列表。
*
* 判据不是「后端白名单的 22 个扩展名」本身——那 22 个扩展名里有 `.go` / `.rs` / `.sql` / `.log`,
* 各家文件管理器给它们报的类型五花八门(`text/x-go`、`application/octet-stream`……)。列窄了
* 会出现「后端收得下、用户却选不到」的假失败,所以这里取**语义上贴近文本/代码**的那一族,
* 并含 `application/octet-stream`(未知扩展名的常见报法)。
*
* **刻意不列**已经确定会被后端拒的那几类(`application/pdf`、`application/zip`、
* `image/svg+xml`、`text/html` 等):让它们在选择器里就是灰的,比让用户传完再看一句
* 「服务端不接受这个格式」更省事。最终裁决仍是**服务端的字节嗅探**——这里放行的,服务端
* 照旧可以用 `415 unsupported_media_type` 拒掉,那条文案会原样显示给用户,不假装成功。
*/
private val DocumentPickerMimeTypes = arrayOf(
"text/*",
"application/json",
"application/x-json",
"application/x-yaml",
"application/yaml",
"text/yaml",
"application/toml",
"application/x-toml",
"application/octet-stream",
)
@Composable
fun ChatScreen(
container: AppContainer,
sessionId: String,
onOpenDrawer: () -> Unit = {},
onLogout: () -> Unit = {},
) {
val viewModel: ChatViewModel = viewModel(
key = sessionId,
factory = viewModelFactory { initializer { ChatViewModel(container, sessionId) } },
)
val state by viewModel.state.collectAsState()
val listState = rememberLazyListState()
// 系统相册选择器(PickVisualMedia 走 Photo Picker,**不需要任何存储权限**)。
// 只把 URI 交给 VM:真正的解码 / 缩放 / base64 在发送时于 IO 线程做。
val imagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } }
// 文件选择器(SAF `OpenMultipleDocuments`):同样不需要任何存储权限——授权是**逐次**给的,
// 因此这条 URI 的读授权只活到本 Activity 任务结束;而发送可能发生在登录之后(分享落地)
// 或很久之后,那时授权还在(同一个任务内)。跨任务的情况由 `ShareIntake` 自己拷进 cache 兜住。
// 一次可选多份(上限由 VM 的准入规则管,超了会如实告知)。
val documentPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments(),
) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) }
// 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事:
// 挑时机、取走一条、交给既有发送路径。
// - **先等连接、但只等一小会儿**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上),
// 抢在那之前发就只换来一次「发送失败,请重试」。等待有上限——真的没网时宁可退化成
// 「内容回到输入框、用户自己重试」,也不要让它停在界面上看不见的地方。
// - **只有当前会话的 ChatScreen 在组合**:已访问过的会话的 VM 都还活着(Activity 级
// ViewModelStore),由 UI 层认领才不会让旧会话把内容抢走。
// - [ShareIntake.take] 是原子出队:同一条内容不会被发两次(重组 / 切会话 / 换连接都会重跑
// 这个 effect)。
val pendingShares by ShareIntake.pending.collectAsState()
LaunchedEffect(sessionId, pendingShares) {
if (pendingShares.isEmpty()) return@LaunchedEffect
withTimeoutOrNull(SHARE_CONNECT_TIMEOUT_MILLIS) {
viewModel.state.first { it.connection == WsState.Connected }
}
ShareIntake.take()?.let { share -> viewModel.sendShared(share, shareNoticeFor(share)) }
}
// 是否跟随底部(微信/QQ 式):只有用户主动滚到别处时才停止跟随。
// 只在「滚动结束」时更新,避免新消息插入导致的锚点位移把状态带偏。
var followBottom by remember { mutableStateOf(true) }
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.distinctUntilChanged()
.collect { scrolling ->
if (!scrolling) {
followBottom = listState.firstVisibleItemIndex == 0 &&
listState.firstVisibleItemScrollOffset < BOTTOM_TOLERANCE
}
}
}
// 最新一项变化(新消息 / 乐观气泡 / 流式气泡 / 落库归一)时,跟随则滚到底。
val newestKey = state.items.lastOrNull()?.key
LaunchedEffect(newestKey) {
if (newestKey != null && followBottom) {
listState.animateScrollToItem(0)
}
}
// reverseLayout 下「列表末尾」就是屏幕顶部;接近末尾时加载更早的一页。
LaunchedEffect(listState) {
snapshotFlow {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 2
}
.distinctUntilChanged()
.collect { nearTop -> if (nearTop) viewModel.loadOlder() }
}
Scaffold(
topBar = {
SmallTopAppBar(
title = "与昔涟",
subtitle = connectionLabel(state),
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(imageVector = MiuixIcons.Basic.Sidebar, contentDescription = "菜单")
}
},
)
},
bottomBar = {
InputBar(
value = state.draft,
imageUri = state.draftImage,
documents = state.draftDocuments,
busyText = busyLabel(state),
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
onPickImage = {
imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
},
onClearImage = viewModel::clearDraftImage,
onPickDocument = { documentPicker.launch(DocumentPickerMimeTypes) },
onRemoveDocument = viewModel::removeDraftDocument,
)
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when {
state.loading && state.items.isEmpty() -> CenterHint("加载中…")
state.items.isEmpty() -> CenterHint("还没有消息")
else -> LazyColumn(
state = listState,
reverseLayout = true,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(items = state.items.asReversed(), key = { it.key }) { item ->
when (item) {
is ChatDisplayItem.Committed -> MessageBubble(item.message)
is ChatDisplayItem.Pending -> PendingBubble(item.item)
ChatDisplayItem.Streaming -> state.streaming?.let { StreamingBubble(it) }
}
}
if (state.hasMore) {
item(key = "__load_older__") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = if (state.loadingOlder) "加载更早的消息…" else "上滑加载更早的消息",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
}
Column(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth(),
) {
state.error?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.error)
}
state.shareNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
state.attachmentNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
}
}
}
}
/** 消息区上方的一行提示:错误与分享告知共用同一形态(铺满宽度、卡片底色)。 */
@Composable
private fun Banner(text: String, color: Color) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
color = color,
modifier = Modifier
.fillMaxWidth()
.background(MiuixTheme.colorScheme.surfaceContainer)
.padding(12.dp),
)
}
@Composable
private fun InputBar(
value: String,
imageUri: String?,
documents: List<DraftDocument>,
busyText: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onPickImage: () -> Unit,
onClearImage: () -> Unit,
onPickDocument: () -> Unit,
onRemoveDocument: (Int) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.imePadding()
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
) {
imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) }
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
DraftDocumentChips(documents = documents, onRemove = onRemoveDocument)
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
if (imageUri == null) {
IconButton(onClick = onPickImage) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
IconButton(onClick = onPickDocument) {
Icon(
imageVector = MiuixIcons.File,
contentDescription = "添加文件附件",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
TextField(
value = value,
onValueChange = onValueChange,
label = "和昔涟说点什么…",
useLabelAsPlaceholder = true,
maxLines = 5,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = onSend,
// 附件可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
// 纯附件回合服务端**支持**)。
enabled = value.isNotBlank() || imageUri != null || documents.isNotEmpty(),
) {
Text("发送")
}
}
// 输入栏下方预留的空间:让输入框/按钮避开屏幕圆角,同时放忙碌提示
Box(
modifier = Modifier
.fillMaxWidth()
.height(InputBottomSpacer)
.padding(start = 12.dp, top = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = busyText,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
/** 待发附件的芯片排:横向可滚(5 枚芯片不挤成两行,也不会把输入框顶上去)。 */
@Composable
private fun DraftDocumentChips(documents: List<DraftDocument>, onRemove: (Int) -> Unit) {
DocumentChipRow(
chips = documents.map(DocumentChips::of),
onRemove = onRemove,
modifier = Modifier.padding(bottom = 6.dp),
)
}
/** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */
@Composable
private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
AsyncImage(
model = uri,
contentDescription = "待发送的图片",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(10.dp)),
)
IconButton(
onClick = onClear,
modifier = Modifier.align(Alignment.TopEnd),
) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "移除图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
@Composable
private fun CenterHint(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
/** 顶栏副标题:只反映连接状态。 */
private fun connectionLabel(state: ChatUiState): String = when (state.connection) {
WsState.Connected -> "已连接"
WsState.Connecting -> "连接中…"
WsState.Reconnecting -> "重连中…"
WsState.Offline -> "离线"
WsState.Idle -> ""
}
/**
* 输入栏下方的忙碌提示。
*
* 三个来源共用这一行:WS 的 `session_busy`([ChatUiState.busyMode])、WS 的
* `pending_ack`/`pending_drain` 与 REST `202 {queued:true,queueDepth}`([ChatUiState.queueDepth])。
* REST 的 202 只给得到条数、给不到忙碌通道,所以队列深度**单独也能成句**——否则 202 之后到
* `session_busy` 之前会有一小段什么都看不见的空档。
*/
private fun busyLabel(state: ChatUiState): String = when {
state.busyMode != null && state.queueDepth > 0 -> "正在思考… 已排队 ${state.queueDepth} 条"
state.busyMode != null -> "正在思考…"
state.queueDepth > 0 -> "已排队 ${state.queueDepth} 条"
else -> ""
}
/**
* 分享落地的如实告知:只在「有东西没能按原样发出」时才成句,正常落地(一条文本 / 一张图 /
* 一个文件)一个字都不说。
*
* 这是**本端渲染层**的信息,绝不写进正文:来源 / 系统提示的边界见 `design/11 §10`
* (`02/F4` 的红线)。截断与读不到要说的话不一样——前者是本端的接收上限,后者是对方给的东西
* 用不了,混成一句会让人以为图还在路上。图片与文件也分开说:两者的上限不是一个数。
*/
private fun shareNoticeFor(share: SharedContent): String? {
val parts = buildList {
if (share.truncatedImages > 0) {
add("分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${share.images.size} 张")
}
if (share.truncatedDocuments > 0) {
add("分享的文件超过 ${ShareRules.MAX_DOCUMENTS} 个,只发送前 ${share.documents.size} 个")
}
if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送")
if (share.failedDocuments > 0) add("有 ${share.failedDocuments} 个文件读不到,未发送")
if (share.isEmpty) add("分享的内容是空的,没有可发送的内容")
}
return parts.takeIf { it.isNotEmpty() }?.joinToString(";")
}
File diff suppressed because it is too large. Load diff
@@ -1,302 +0,0 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.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.theme.MiuixTheme
/** 流式进行中的光标。 */
private const val CURSOR = "▍"
/** 附件芯片排的最大宽度:与气泡同级,避免 5 枚芯片把整行铺满屏幕。 */
private val CHIP_ROW_MAX_WIDTH = 320.dp
/**
* 一条落库消息渲染成一个或多个气泡:
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
* - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
* - `image` 非空时在气泡上方显示图片。
*
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
*/
@Composable
fun MessageBubble(message: HistoryMessageDto) {
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)) }
}
MessageGroup(isUser = isUser, footer = messageSourceLabel(message, LocalEnumCatalog.current)) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
DocumentChipRow(chips = message.documents.map(DocumentChips::of))
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
bubbles.forEachIndexed { index, bubble ->
Bubble(bubble, isUser, isLast = index == bubbles.lastIndex)
}
}
}
/**
* 乐观发送中的用户消息(还没落库)。
*
* 图片显示的是**本地** `content://` URI(不是上行的那段 base64):预览不需要联网、
* 也不必把几 MB 的字符串塞进 UI 状态。落库后由 `message.image` 接管。
* 附件同理显示**本地显示名**(那时还没有服务端存储名,也还没有任何服务端结论)。
*/
@Composable
fun PendingBubble(item: PendingMessage) {
MessageGroup(isUser = true, footer = "发送中…") {
DocumentChipRow(chips = item.documents.map(DocumentChips::of))
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
}
/**
* 附件芯片排:横向可滚(最多 5 枚也不挤成两行),一枚一枚展示
* 「名字(中略,保住扩展名)/ 类型 · 体积 · 字数 · 状态」。
*
* [onRemove] 非空即带移除按钮——待发位(输入栏上方)要能反悔,历史气泡里则不给。
*
* 附件可能是空列表(绝大多数消息都是),此时**不占位、不留空白**。
*/
@Composable
fun DocumentChipRow(
chips: List<DocumentChipModel>,
modifier: Modifier = Modifier,
onRemove: ((Int) -> Unit)? = null,
) {
if (chips.isEmpty()) return
Row(
modifier = modifier
.widthIn(max = CHIP_ROW_MAX_WIDTH)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
chips.forEachIndexed { index, chip ->
DocumentChip(model = chip, onRemove = onRemove?.let { remove -> { remove(index) } })
}
}
}
/**
* 一枚附件芯片。
*
* 状态分两处说,**不许互相顶替**(契约 v0.59 §1.2):
* - `status:"unreadable"` = **昔涟读不到这个文件** → 整枚转灰 + 明写「读不到」;
* - `truncated:true` = **注入被预算截断**(正文照样进了上下文)→ 只加一句「注入已截断」。
*
* 未知 `status` 原样显示(开放集合,见 [DocumentChips.statusLabel])。
*/
@Composable
private fun DocumentChip(model: DocumentChipModel, onRemove: (() -> Unit)?) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
if (model.unreadable) {
MiuixTheme.colorScheme.surfaceContainer
} else {
MiuixTheme.colorScheme.surfaceContainerHigh
},
)
.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,
)
}
}
}
}
/** 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。 */
@Composable
fun StreamingBubble(streaming: StreamingState) {
MessageGroup(isUser = false) {
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,
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()
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)
if (footer != null) {
Text(
text = footer,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
)
}
}
}
@Composable
private fun Bubble(
bubble: ParsedBubble,
isUser: Boolean,
isLast: Boolean,
showCursor: 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)
AnnotationLine(bubble.tail)
}
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
@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,
)
}
}
@Composable
private fun BubbleBody(text: String, isUser: Boolean) {
Box(
modifier = Modifier
.widthIn(max = 300.dp)
.background(
color = if (isUser) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.surfaceContainer
},
shape = RoundedCornerShape(18.dp),
)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
SelectionContainer {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = if (isUser) MiuixTheme.colorScheme.onPrimary else MiuixTheme.colorScheme.onSurfaceContainer,
)
}
}
}
@Composable
private fun ChatImage(model: String) {
AsyncImage(
model = model,
contentDescription = null,
modifier = Modifier
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(14.dp)),
)
}
private fun plainBubble(text: String) = ParsedBubble(
body = text,
parts = emptyList(),
head = emptyList(),
tail = emptyList(),
)
@@ -1,39 +0,0 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.enums.EnumCatalog
/**
* 来源角标(S1.3):每条消息默认显示来源。
* - 主人发的 → 「来自 X」(从哪一端发出)
* - 昔涟发的 → 「发送至 X」(回到哪一端)
* 优先用 `deviceId` 区分具体端,再回落到 `platform`;两者都没有则不显示。
*
* 平台名到标签的映射来自 `/meta/enums` 的权威表([catalog]),**不含任何硬编码表**:
* 表里没有的取值原样显示(红线),未登录 / 尚无快照时全部原样。
*/
fun messageSourceLabel(message: HistoryMessageDto, catalog: EnumCatalog): String? {
val name = sourceName(message, catalog) ?: return null
val verb = if (message.role == "assistant") "发送至" else "来自"
return "$verb $name"
}
private fun sourceName(message: HistoryMessageDto, catalog: EnumCatalog): String? {
val deviceId = message.deviceId
if (!deviceId.isNullOrBlank()) {
// 下面两条是 device 粒度、与平台表无关的端标识,不参与权威表映射。
if (deviceId.startsWith("voiceassist")) return "安卓"
if (deviceId.startsWith("web:")) {
val browser = deviceId.removePrefix("web:").substringBefore(':')
return "Web($browser)"
}
// IM 端 deviceId 形如 <平台名>:<配置id>:<uid>,只取平台名做友好显示;
// 平台名不在表里(服务端新增渠道)则整串原样显示,不丢信息也不猜。
val platform = deviceId.substringBefore(':')
return catalog.platformLabelOrNull(platform) ?: deviceId
}
val platform = message.platform
if (!platform.isNullOrBlank()) return catalog.platformLabel(platform)
return null
}
@@ -1,157 +0,0 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.Color
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 思维链卡片。流式期间展开(方便观察),流式结束自动折叠;历史思维链默认折叠。
* 正文包在 [SelectionContainer] 里,长按可自由选择、菜单「全选」只作用于本卡片。
*/
@Composable
fun ThinkingCard(
text: String,
active: Boolean = false,
modifier: Modifier = Modifier,
) {
if (text.isBlank() && !active) return
val defaultExpanded = LocalAppPrefs.current.thinkingExpanded
var expanded by remember { mutableStateOf(active && defaultExpanded) }
LaunchedEffect(active) {
if (!active) expanded = false
}
FoldCard(modifier = modifier, onClick = { expanded = !expanded }) {
FoldCardHeader(
title = if (active) "思考中…" else "思维链",
expanded = expanded,
)
if (expanded && text.isNotBlank()) {
Spacer(Modifier.height(6.dp))
FoldCardBody(text)
}
}
}
/** 工具调用卡片:参数与执行输出分开显示,默认折叠。标题/配色由 `tool_execution.*` 的 phase 驱动。 */
@Composable
fun ToolCard(
tool: ToolCallState,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val name = tool.tool.takeIf { it.isNotBlank() }
val title = when (tool.phase) {
ToolPhase.Running -> name?.let { "执行 $it…" } ?: "执行中…"
ToolPhase.Done -> name?.let { "工具调用 · $it" } ?: "工具调用"
ToolPhase.Error -> name?.let { "工具调用失败 · $it" } ?: "工具调用失败"
null -> if (tool.done) name?.let { "工具调用 · $it" } ?: "工具调用" else "调用工具…"
}
val titleColor = if (tool.phase == ToolPhase.Error) {
MiuixTheme.colorScheme.error
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
}
FoldCard(modifier = modifier, onClick = { expanded = !expanded }) {
FoldCardHeader(
title = title,
expanded = expanded,
titleColor = titleColor,
)
if (expanded) {
if (tool.arguments.isNotBlank()) {
Spacer(Modifier.height(6.dp))
FoldCardLabel("参数")
FoldCardBody(tool.arguments)
}
if (tool.output.isNotBlank()) {
Spacer(Modifier.height(6.dp))
FoldCardLabel("结果")
FoldCardBody(tool.output)
}
}
}
}
@Composable
private fun FoldCard(
modifier: Modifier,
onClick: () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.background(MiuixTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(14.dp))
.clickable(onClick = onClick)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
content = content,
)
}
@Composable
private fun FoldCardHeader(
title: String,
expanded: Boolean,
titleColor: Color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = title,
style = MiuixTheme.textStyles.footnote1,
color = titleColor,
modifier = Modifier.weight(1f),
)
Text(
text = if (expanded) "收起" else "展开",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.primary,
)
}
}
@Composable
private fun FoldCardLabel(text: String) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.primary,
)
}
@Composable
private fun FoldCardBody(text: String) {
SelectionContainer {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
@@ -1,25 +0,0 @@
package top.yeij.cyrene.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun LoadingScreen() {
Scaffold { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
Text("加载中…", style = MiuixTheme.textStyles.main)
}
}
}
@@ -1,226 +0,0 @@
package top.yeij.cyrene.ui.common
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.ceil
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 完全关闭时抽屉面板的位移比例(面板整体在屏幕左侧之外)。 */
private const val ClosedFraction = 0f
/** 完全打开时抽屉面板的位移比例。 */
private const val OpenFraction = 1f
/** 位移大于该值才认为抽屉「露出」,用于决定是否挂载 scrim / 拖拽手势。 */
private const val VisibleFractionEpsilon = 0.001f
/** 松手时位移超过面板宽度的一半即吸附到打开。 */
private const val PositionalThreshold = 0.5f
/** 打开 / 关闭吸附动画时长。 */
private const val SettleAnimationMillis = 240
/** 触发「速度吸附」的阈值,与 AnchoredDraggableDefaults 的默认值保持一致。 */
private val FlingVelocity = 125.dp
/**
* 自研左滑抽屉(Miuix 组件库没有 Drawer,故自行实现)。
*
* 设计约定:
* - 只能由汉堡按钮等外部入口打开,**不做左边缘滑出**(会和系统返回手势冲突),
* 因此关闭状态下不挂载任何拖拽 / 点击手势;
* - 抽屉露出时,在面板或遮罩上横向拖拽即可关闭;
* - 返回键优先关闭抽屉;
* - 关闭状态下遮罩与面板都不参与命中测试,避免挡住聊天页等底层内容。
*
* 拖拽 API 选用 [Modifier.draggable] 而不是 `anchoredDraggable`:
* `AnchoredDraggableState` 的构造器 / `settle` 重载在 Compose 1.11.2 中已被标记
* `@Deprecated`(阈值与动画需改走 `AnchoredDraggableDefaults.flingBehavior`),
* 版本间签名变动较大;而 [Modifier.draggable] 长期稳定且无实验性注解,
* 配合 [Animatable] 手动实现「位移过半 / 速度超阈值」的吸附逻辑,行为完全可控。
*
* 纵向滚动与横向拖拽的冲突:这里**不会**给 [drawerContent] 外层套 `verticalScroll`,
* 否则调用方在内部放 `LazyColumn` 时会拿到无限高约束而崩溃。调用方若需要滚动,
* 请在 [drawerContent] 内部自行使用 `LazyColumn` 或给自定义 Column 加 `verticalScroll`。
* 横向 `draggable` 与纵向滚动靠触摸 slop 的轴向判定自然区分:谁先超过 slop 谁接管手势,
* 因此纵向滑动列表、横向拖拽关闭可以共存。
*
* @param open 抽屉是否打开(单向数据流,真实位移由内部 [Animatable] 维护)。
* @param onOpenChange 请求切换开合状态;返回键、点击遮罩、拖拽吸附都会回调。
* @param drawerWidth 抽屉面板宽度。
* @param content 主内容,铺满整个容器,位于遮罩与抽屉之下。
* @param drawerContent 抽屉面板内容,带 [ColumnScope],可放任意 Miuix 组件。
*/
@Composable
fun MiuixDrawer(
open: Boolean,
onOpenChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
drawerWidth: Dp = 300.dp,
content: @Composable () -> Unit,
drawerContent: @Composable ColumnScope.() -> Unit,
) {
val density = LocalDensity.current
val drawerWidthPx = with(density) { drawerWidth.toPx() }.coerceAtLeast(1f)
val flingVelocityPx = with(density) { FlingVelocity.toPx() }
// open 只决定动画目标值,实际位移用 Animatable 维护:
// 拖拽中的中间态、吸附动画、外部状态切换三者互不覆盖。
val offsetFraction = remember { Animatable(if (open) OpenFraction else ClosedFraction) }
val currentOnOpenChange by rememberUpdatedState(onOpenChange)
val scope = rememberCoroutineScope()
// derivedStateOf 只在布尔值翻转时触发重组,动画过程中不会逐帧重组。
val drawerVisible by remember {
derivedStateOf { offsetFraction.value > VisibleFractionEpsilon }
}
// 拖拽手势的挂载条件必须包含 open:拖到完全关闭的瞬间 fraction 会先归零,
// 若这时就摘掉手势节点,onDragStopped 不会触发,外部 open 状态会失去同步。
val interactionEnabled = open || drawerVisible
LaunchedEffect(open) {
offsetFraction.animateTo(
targetValue = if (open) OpenFraction else ClosedFraction,
animationSpec = tween(
durationMillis = SettleAnimationMillis,
easing = FastOutSlowInEasing,
),
)
}
BackHandler(enabled = open) { currentOnOpenChange(false) }
// snapTo 是挂起函数,而 DraggableState 的回调不是挂起上下文,
// 因此放到组合作用域里启动;Animatable 的 MutatorMutex 会取消正在进行的开合动画。
val dragState = rememberDraggableState { delta ->
scope.launch {
offsetFraction.snapTo(
(offsetFraction.value + delta / drawerWidthPx)
.coerceIn(ClosedFraction, OpenFraction),
)
}
}
Box(modifier = modifier.fillMaxSize()) {
content()
// 始终组合遮罩与面板,以保留 drawerContent(如 LazyColumn)的内部状态;
// 但只有在抽屉露出(或正在被拖拽)时才挂载手势,关闭状态下整块对触摸完全透明。
Box(
modifier = Modifier
.matchParentSize()
.then(
if (interactionEnabled) {
Modifier.draggable(
state = dragState,
orientation = Orientation.Horizontal,
onDragStopped = { velocity ->
val target = resolveTarget(
fraction = offsetFraction.value,
velocity = velocity,
flingVelocityPx = flingVelocityPx,
)
currentOnOpenChange(target == OpenFraction)
offsetFraction.animateTo(
targetValue = target,
animationSpec = tween(
durationMillis = SettleAnimationMillis,
easing = FastOutSlowInEasing,
),
)
},
)
} else {
Modifier
},
),
) {
// 遮罩:关闭时 alpha 为 0 且不挂 pointerInput,因此绝不吃掉触摸事件。
Box(
modifier = Modifier
.matchParentSize()
// 在绘制阶段读 Animatable,避免逐帧重组。
// windowDimming 是 Miuix 主题自带的半透明黑遮罩色(随浅色/深色主题变化),
// 比硬编码 Color.Black.copy(alpha = 0.32f) 更贴合 HyperOS 视觉;
// 若觉得偏重,可换成后者。
.graphicsLayer { alpha = offsetFraction.value }
.background(MiuixTheme.colorScheme.windowDimming)
.then(
if (drawerVisible) {
Modifier.pointerInput(Unit) {
detectTapGestures { currentOnOpenChange(false) }
}
} else {
Modifier
},
),
)
Column(
modifier = Modifier
.width(drawerWidth)
.fillMaxHeight()
// 关闭时 x = -drawerWidthPx,面板整体移出屏幕左侧:
// 既看不见,也不会落在任何屏幕内坐标的命中范围内。
// 用 ceil 取整,保证即使宽度是小数也至少偏移满一个面板宽度。
.offset {
IntOffset(
x = -ceil(drawerWidthPx * (OpenFraction - offsetFraction.value)).toInt(),
y = 0,
)
}
.background(MiuixTheme.colorScheme.surface)
// 面板铺满全高(背景仍到底),内容再避开状态栏 / 导航栏
.windowInsetsPadding(WindowInsets.systemBars),
) {
drawerContent()
}
}
}
}
/**
* 松手吸附目标:速度超过阈值按方向吸附,否则按位移是否过半判断。
*/
private fun resolveTarget(
fraction: Float,
velocity: Float,
flingVelocityPx: Float,
): Float = when {
velocity > flingVelocityPx -> OpenFraction
velocity < -flingVelocityPx -> ClosedFraction
fraction >= PositionalThreshold -> OpenFraction
else -> ClosedFraction
}
@@ -1,38 +0,0 @@
package top.yeij.cyrene.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* Miuix 的 `BasicComponent` / Preference 系列本身**没有背景与圆角**(只是可点击的 Column),
* 需要外面包一层卡片容器才能得到 MD3 的「小圆角卡片」外观。
*
* 页面底色用 `surface`、卡片用 `surfaceContainer`:浅色下 #F7F7F7 对 #FFFFFF,
* 深色下 #000000 对 #242424,卡片因此能凸显出来。
*/
@Composable
fun PreferenceCard(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = CardHorizontalMargin)
.clip(RoundedCornerShape(16.dp))
.background(MiuixTheme.colorScheme.surfaceContainer),
) {
content()
}
}
/** 卡片与屏幕左右两侧的间距(Miuix 的 Preference 组件也是这个量级)。 */
private val CardHorizontalMargin = 12.dp
@@ -1,143 +0,0 @@
package top.yeij.cyrene.ui.login
import androidx.compose.foundation.clickable
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.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
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.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun LoginScreen(container: AppContainer) {
val viewModel: LoginViewModel = viewModel(
factory = viewModelFactory {
initializer { LoginViewModel(container.authManager, container.sessionStore) }
},
)
val state by viewModel.state.collectAsState()
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(72.dp))
Text("Cyrene", style = MiuixTheme.textStyles.title1)
Spacer(Modifier.height(4.dp))
Text(
text = "登录后与昔涟聊天",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Spacer(Modifier.height(32.dp))
Text(
text = if (state.serverExpanded) "服务器地址(点击收起)" else "服务器地址(点击展开)",
style = MiuixTheme.textStyles.subtitle,
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.toggleServerExpanded() }
.padding(vertical = 8.dp),
)
if (state.serverExpanded) {
TextField(
value = state.baseUrl,
onValueChange = viewModel::onBaseUrlChange,
label = "http://192.168.1.10:8080",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
}
TextField(
value = state.username,
onValueChange = viewModel::onUsernameChange,
label = "用户名",
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
TextField(
value = state.password,
onValueChange = viewModel::onPasswordChange,
label = "密码",
singleLine = true,
visualTransformation = if (state.passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { viewModel.submit() }),
trailingIcon = {
IconButton(onClick = viewModel::togglePasswordVisible) {
Text(
text = if (state.passwordVisible) "隐藏" else "显示",
style = MiuixTheme.textStyles.footnote1,
)
}
},
modifier = Modifier.fillMaxWidth(),
)
if (state.error != null) {
Spacer(Modifier.height(12.dp))
Text(
text = state.error.orEmpty(),
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(28.dp))
Button(
onClick = viewModel::submit,
enabled = !state.loading,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.loading) "登录中…" else "登录")
}
Spacer(Modifier.height(32.dp))
}
}
}
@@ -1,73 +0,0 @@
package top.yeij.cyrene.ui.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import top.yeij.cyrene.core.auth.AuthManager
import top.yeij.cyrene.core.auth.SessionStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class LoginUiState(
val baseUrl: String = "",
val username: String = "",
val password: String = "",
val passwordVisible: Boolean = false,
val serverExpanded: Boolean = false,
val loading: Boolean = false,
val error: String? = null,
)
class LoginViewModel(
private val authManager: AuthManager,
sessionStore: SessionStore,
) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
init {
val stored = sessionStore.load()
_state.update {
it.copy(
baseUrl = stored.baseUrl,
username = stored.username.orEmpty(),
serverExpanded = stored.baseUrl.isBlank(),
)
}
}
fun onBaseUrlChange(value: String) = _state.update { it.copy(baseUrl = value, error = null) }
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun togglePasswordVisible() = _state.update { it.copy(passwordVisible = !it.passwordVisible) }
fun toggleServerExpanded() = _state.update { it.copy(serverExpanded = !it.serverExpanded) }
fun submit() {
val snapshot = _state.value
if (snapshot.loading) return
val validationError = when {
snapshot.baseUrl.isBlank() -> "请填写服务器地址"
snapshot.username.isBlank() -> "请填写用户名"
snapshot.password.isBlank() -> "请填写密码"
else -> null
}
if (validationError != null) {
_state.update { it.copy(error = validationError, serverExpanded = true) }
return
}
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
val error = authManager.login(snapshot.baseUrl, snapshot.username.trim(), snapshot.password)
_state.update { it.copy(loading = false, error = error?.userMessage) }
}
}
}
@@ -1,104 +0,0 @@
package top.yeij.cyrene.ui.sessions
import top.yeij.cyrene.data.api.dto.UserPersonaDto
/**
* 「新建会话时选角色」的纯逻辑:弹层选项的构造、会话行上的角色名回填。
* 抽在这里是为了能直接单测——不依赖 Compose,也不依赖服务端。
*/
/**
* 弹层里的一项。[personaId] 为 null 就是**「默认角色」**:不绑 personaId,跟随全局默认角色。
*
* [title] 一定非空(服务端 `name` 为空白时回落到 `id`);[description] 为 null 表示
* **没有简述**,此时只显示名字,不编占位符。
*/
data class PersonaChoice(
val personaId: String?,
val title: String,
val description: String?,
)
/** 「默认角色」那一行的标题([PersonaChoice.personaId] 为 null 的那一项恒是它)。 */
internal const val DEFAULT_PERSONA_TITLE = "默认角色"
/** 「默认角色」的简述:只写契约里写明的事。 */
private const val DEFAULT_PERSONA_DESCRIPTION = "不绑定角色,跟随全局默认"
/**
* 构造弹层选项:**「默认角色」恒在第一位**,其余按服务端顺序原样排——服务端是排序的权威,
* 客户端不重排、不去重、不丢弃不认识的取值。
*
* 角色名空白时回落到 `id`(与管理页同一口径:宁可显示一个 id,也不显示空行);
* `character` 空白 = 没有简述,只显示名字。
*/
internal fun personaChoices(personas: List<UserPersonaDto>): List<PersonaChoice> = buildList {
add(
PersonaChoice(
personaId = null,
title = DEFAULT_PERSONA_TITLE,
description = defaultRoleDescription(personas),
),
)
personas.forEach { persona ->
add(
PersonaChoice(
personaId = persona.id,
title = persona.name.ifBlank { persona.id },
description = persona.character.takeIf { it.isNotBlank() },
),
)
}
}
/**
* 「默认角色」那一行的简述:能看出全局默认是哪个角色时**点名**它,免得弹层里两行都叫「默认」
* 分不清——服务端 `isDefault` 标记的那一位就是 `personaId` 省略时的回落目标。
*
* 角色表里没有任何默认标记(或它没有名字也没有 id)时不点名:**不猜**。
*/
private fun defaultRoleDescription(personas: List<UserPersonaDto>): String {
val fallback = personas.firstOrNull { it.isDefault }?.let { it.name.ifBlank { it.id } }
return if (fallback.isNullOrBlank()) {
DEFAULT_PERSONA_DESCRIPTION
} else {
"$DEFAULT_PERSONA_DESCRIPTION(当前「$fallback」)"
}
}
/**
* 会话行上的角色名:**只有真的绑了角色(`personaId` 非空白)才显示**。
*
* - 绑了 + 角色表里有 → 角色名;
* - 绑了 + 角色表里没有(角色表没拉回来 / 角色被改名或删掉)→ **原样显示那个 id**,
* 不猜、不硬编码任何映射表;
* - `personaId` 空/缺省(继承全局默认角色)→ null(不显示):那是绝大多数会话的常态,
* 每行都挂一个「默认」只是噪声。
*/
internal fun personaLabelFor(personaId: String?, personas: List<PersonaChoice>): String? {
val id = personaId?.takeIf { it.isNotBlank() } ?: return null
return personas.firstOrNull { it.personaId == id }?.title ?: id
}
/** 会话行的摘要行文本:`[角色名 ·] 最后一条消息预览`;两者都没有 → null(该行不画摘要)。 */
internal fun sessionSummaryText(personaLabel: String?, preview: String?): String? =
listOfNotNull(personaLabel, preview)
.filter { it.isNotBlank() }
.takeIf { it.isNotEmpty() }
?.joinToString(" · ")
/**
* 角色目录的拉取结果 → 新状态(纯函数)。
*
* **只有成功才动列表**:失败保留上一次拿到的列表(一次网络抖动不该把好数据擦掉),
* 也不把 [SessionsUiState.personasLoaded] 打回 false——拿到过就是拿到过,那时列表里还是有内容的。
* 失败只把原因写进 [SessionsUiState.personaError]:弹层里显示它,用默认角色建会话这条路不受影响。
*/
internal fun SessionsUiState.withPersonaResult(
loaded: List<UserPersonaDto>? = null,
failure: String? = null,
): SessionsUiState = if (loaded != null) {
copy(personas = personaChoices(loaded), personasLoaded = true, personaError = null)
} else {
copy(personaError = failure)
}
@@ -1,502 +0,0 @@
package top.yeij.cyrene.ui.sessions
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.Badge
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
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.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Add
import top.yukonga.miuix.kmp.icon.extended.Delete
import top.yukonga.miuix.kmp.icon.extended.Pin
import top.yukonga.miuix.kmp.icon.extended.Unpin
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 侧边栏内容:会话列表 + IoT/设置入口。列表项为 MD3 小圆角卡片。
*
* 行内交互:单击切换会话、**长按重命名**、行尾置顶/取消置顶;删除只对次级会话开放。
* 标题是服务端的自动标题(首条用户消息生成),没有就落到兜底标题。
*
* ## 新建会话要先选角色
* 「+」不再直接建会话,而是先开 [NewSessionDialog]。理由是角色**只在创建时**能定、之后不可变
* (`PATCH /sessions/{id}` 收到 `personaId` 一律 400),选错了只能删掉重开——这一步值得停下来问一次。
*
* @param onCreate 参数是这次要绑定的角色 id:**null = 「默认角色」**(不绑,跟随全局默认角色)。
* 类型必须是 `(String?) -> Unit`:调用方传的是 `SessionsViewModel::createSession` 这个方法引用,
* 两边签名逐字对应。
*/
@Composable
fun ColumnScope.SessionDrawerContent(
state: SessionsUiState,
onSelect: (String) -> Unit,
onCreate: (String?) -> Unit,
onDelete: (SessionItem) -> Unit,
onTogglePin: (SessionItem) -> Unit,
onRename: (String, String) -> Unit,
onOpenIot: () -> Unit,
onOpenAdmin: () -> Unit,
onOpenSettings: () -> Unit,
) {
var pendingDelete by remember { mutableStateOf<SessionItem?>(null) }
var pendingRename by remember { mutableStateOf<SessionItem?>(null) }
var pendingNewSession by remember { mutableStateOf(false) }
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) {
Text("Cyrene", style = MiuixTheme.textStyles.title2)
Text(
text = "与昔涟",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("会话", style = MiuixTheme.textStyles.subtitle, modifier = Modifier.weight(1f))
IconButton(onClick = { pendingNewSession = true }) {
Icon(imageVector = MiuixIcons.Add, contentDescription = "新建会话")
}
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = state.sessions, key = { it.id }) { session ->
SessionRow(
session = session,
// 角色名在这里回填而不是在 VM 里拼进 SessionItem:这里能拿到角色表(也就能表达
// 「角色表还没回来」的降级),而且 [personaLabelFor] 是一条可单测的纯函数。
personaLabel = personaLabelFor(session.personaId, state.personas),
active = session.id == state.activeSessionId,
onClick = { onSelect(session.id) },
onTogglePin = { onTogglePin(session) },
onRename = { pendingRename = session },
onDelete = { pendingDelete = session },
)
}
}
if (state.error != null) {
Text(
text = state.error.orEmpty(),
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
)
}
if (state.fromCache && state.syncedAt > 0) {
Text(
text = "网络异常,显示 ${relativeTime(state.syncedAt)} 的结果",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
)
} else if (state.loading) {
Text(
text = "同步中…",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
)
}
Spacer(Modifier.height(12.dp))
PreferenceCard { BasicComponent(title = "管理员监控", onClick = onOpenAdmin) }
Spacer(Modifier.height(8.dp))
PreferenceCard { BasicComponent(title = "IoT(规划中)", onClick = onOpenIot) }
Spacer(Modifier.height(8.dp))
PreferenceCard { BasicComponent(title = "App 设置", onClick = onOpenSettings) }
Spacer(Modifier.height(16.dp))
pendingDelete?.let { target ->
ConfirmDialog(
title = "删除「${target.title}」?",
message = "该会话的全部消息会一并删除,且不可恢复。",
confirmText = "删除",
onConfirm = {
onDelete(target)
pendingDelete = null
},
onDismiss = { pendingDelete = null },
)
}
pendingRename?.let { target ->
RenameDialog(
initial = target.title,
onConfirm = { title ->
onRename(target.id, title)
pendingRename = null
},
onDismiss = { pendingRename = null },
)
}
if (pendingNewSession) {
NewSessionDialog(
personas = state.personas,
personasLoaded = state.personasLoaded,
personaError = state.personaError,
onConfirm = { personaId ->
onCreate(personaId)
pendingNewSession = false
},
onDismiss = { pendingNewSession = false },
)
}
}
/**
* 「新建会话」的角色弹层。
*
* 用 Compose 的 `Dialog` + Miuix 内容,与同文件的重命名 / 删除确认**同一套做法**:抽屉只有 300dp 宽,
* 角色列表需要一块能盖住全屏的空间,也没必要去猜 Miuix 的弹层组件 API。选项行仍是
* `PreferenceCard { BasicComponent(...) }`(Miuix 列表项的底座,自身没有背景与圆角)。
*
* 选中项是一个 `String?`,与 [PersonaChoice.personaId] 同一口径(null = 「默认角色」);
* **每次打开都从「默认角色」开始**——新建会话是一次新决定,不沿用上一次的选择。
*
* 角色目录没拉回来时,这里只剩「默认角色」:**正在拉**与**服务端真的没有角色**要分开说
* ([personasLoaded]),失败则把原因也摆出来。降级目标是「照样能用默认角色建会话」,
* 而不是把新建会话整个挡掉。
*/
@Composable
private fun NewSessionDialog(
personas: List<PersonaChoice>,
personasLoaded: Boolean,
personaError: String?,
onConfirm: (String?) -> Unit,
onDismiss: () -> Unit,
) {
var selected by remember { mutableStateOf<String?>(null) }
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(4.dp))
Text(
text = "选择这个会话的角色。角色创建后不可更改,要换角色请新开会话。",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Spacer(Modifier.height(12.dp))
Column(
modifier = Modifier
.heightIn(max = PERSONA_LIST_MAX_HEIGHT)
.verticalScroll(rememberScrollState()),
) {
personas.forEach { choice ->
PreferenceCard {
BasicComponent(
title = choice.title,
summary = choice.description,
onClick = { selected = choice.personaId },
endActions = {
if (selected == choice.personaId) {
Text(
text = "✓",
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.primary,
)
}
},
)
}
Spacer(Modifier.height(8.dp))
}
}
if (!personasLoaded && personaError == null) {
// 「还没拉到」不能长得像「服务端没有角色」——那会让人以为角色功能没了。
Text(
text = "角色列表加载中…",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Spacer(Modifier.height(8.dp))
}
if (personaError != null) {
Text(
text = "角色列表加载失败:$personaError",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton("取消", onClick = onDismiss)
Spacer(Modifier.width(8.dp))
TextButton("创建", onClick = { onConfirm(selected) })
}
}
}
}
/** 弹层里角色列表的最大高度:超出就滚动,别把「创建」按钮挤出窄屏的可见区。 */
private val PERSONA_LIST_MAX_HEIGHT = 320.dp
/**
* 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按,
* `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable,
* 子级会先吃掉按下事件,长按就永远不会触发)。
*
* ## 为什么不用 `BasicComponent` 的 `title` / `summary` 参数
* 那两个参数只收 `String`,塞不下未读角标;而角标**不能**放进 `endActions`——
* 抽屉固定 300dp,卡片 + 内边距之后只剩约 244dp,而 `endActions` 里已经有三个 40dp 的
* 图标按钮(120dp),`BasicComponent` 又把它内部可用宽度的 60% 作为 `endActions` 的硬上限:
* 再塞一个角标会把图标挤到裁剪(Row 不换行),标题列也被压到约 95dp。
* 所以这里改用「内容重载」自己搭标题行:`[标题][角标]` 同行,角标紧挨标题、不占额外列宽。
*
* 代价是标题 / 摘要的字号、字重、颜色要自己写,取值与库内实现(`Component.kt`)逐字一致
* (`headline1.fontSize` + `Medium` + `BasicComponentDefaults.titleColor()`;
* `body2.fontSize` + `summaryColor()`),**唯一的差异是收紧了行数**:
* 标题与摘要都 `maxLines = 1` + 省略号——这正是「可读性要强」要的效果:长标题不再在约 116dp 的
* 列里折成两三行把卡片撑高,而是稳定地占一行、尾部省略,列表因此等高、可扫读。
*
* @param personaLabel 该会话绑定的**角色名**([personaLabelFor] 的回填;拿不到就不显示)。
* 它挂在摘要行前面——「这个会话是谁在答话」比最后一条消息更该先被看到。
*/
@Composable
private fun SessionRow(
session: SessionItem,
personaLabel: String?,
active: Boolean,
onClick: () -> Unit,
onTogglePin: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
val titleColor = BasicComponentDefaults.titleColor().color
val summaryColor = BasicComponentDefaults.summaryColor().color
val badge = unreadBadgeText(session.unreadCount)
val summary = sessionSummaryText(personaLabel, session.lastMessage)
PreferenceCard {
BasicComponent(
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onRename),
endActions = {
if (active) {
Text(
text = "✓",
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
IconButton(onClick = onTogglePin) {
Icon(
imageVector = if (session.pinned) MiuixIcons.Unpin else MiuixIcons.Pin,
contentDescription = if (session.pinned) "取消置顶" else "置顶",
tint = if (session.pinned) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
}
if (!session.isMain) {
IconButton(onClick = onDelete) {
Icon(
imageVector = MiuixIcons.Delete,
contentDescription = "删除会话",
tint = MiuixTheme.colorScheme.error,
)
}
}
},
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = session.title,
// 标题是这一行唯一的权重子项,`fill = false` 让短标题只占自己需要的宽度。
// 真正保证角标不被挤掉的是 Row 的测量顺序:**非权重子项(角标与它前面的间距)
// 先量**,权重只分配剩下的空间,标题再在这块空间里省略——
// 所以 `99+` 永远进得来,标题也不会被压到完全看不见。
modifier = Modifier.weight(1f, fill = false),
fontSize = MiuixTheme.textStyles.headline1.fontSize,
fontWeight = FontWeight.Medium,
color = titleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (badge != null) {
Spacer(Modifier.width(UNREAD_BADGE_GAP))
// Miuix 自带角标:错误色药丸 + `onError` 文字 + 16dp 下限高度,
// 在 `surfaceContainer` 卡片上对比度足够;`99+` 会自然撑成药丸而不会溢出。
Badge(
modifier = Modifier.clearAndSetSemantics {
contentDescription = "未读 $badge"
},
) {
Text(badge)
}
}
}
summary?.let { line ->
Text(
text = line,
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = summaryColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** 标题与角标之间的间距:太小会粘成一个词,太大又脱离标题。 */
private val UNREAD_BADGE_GAP = 6.dp
/** 重命名对话框:服务端不接受空标题(400)也不接受超过 80 个字符,两者都在这里挡住。 */
@Composable
private fun RenameDialog(
initial: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember(initial) { mutableStateOf(initial) }
val trimmed = name.trim()
val used = trimmed.runes()
val tooLong = used > TITLE_MAX_RUNES
val canSave = trimmed.isNotEmpty() && !tooLong
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(12.dp))
TextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
label = "会话名称",
singleLine = true,
)
Spacer(Modifier.height(6.dp))
Text(
text = "$used / $TITLE_MAX_RUNES",
style = MiuixTheme.textStyles.footnote2,
color = if (tooLong) {
MiuixTheme.colorScheme.error
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
Spacer(Modifier.height(20.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton("取消", onClick = onDismiss)
Spacer(Modifier.width(8.dp))
TextButton(
text = "保存",
onClick = { onConfirm(trimmed) },
enabled = canSave,
)
}
}
}
}
@Composable
private fun ConfirmDialog(
title: String,
message: String,
confirmText: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
.padding(20.dp),
) {
Text(title, style = MiuixTheme.textStyles.title3)
Spacer(Modifier.height(8.dp))
Text(
text = message,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Spacer(Modifier.height(20.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton("取消", onClick = onDismiss)
Spacer(Modifier.width(8.dp))
TextButton(confirmText, onClick = onConfirm)
}
}
}
}
/** 服务端 `PATCH /sessions/{id}` 的上限:超过 80 个字符直接 400。 */
private const val TITLE_MAX_RUNES = 80
/** 按 Unicode 码点计数,与服务端 Go 的「rune」口径一致(代理对算一个)。 */
private fun String.runes(): Int = codePointCount(0, length)
private fun relativeTime(timestamp: Long): String {
val minutes = (System.currentTimeMillis() - timestamp) / 60_000
return when {
minutes < 1 -> "刚刚"
minutes < 60 -> "$minutes 分钟前"
minutes < 24 * 60 -> "${minutes / 60} 小时前"
else -> "${minutes / (24 * 60)} 天前"
}
}
@@ -1,481 +0,0 @@
package top.yeij.cyrene.ui.sessions
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.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.CyreneException
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent
data class SessionItem(
val id: String,
val title: String,
val isMain: Boolean,
val pinned: Boolean,
/** 最后一条消息的一行摘要;会话还没有消息时为 null。 */
val lastMessage: String?,
/**
* 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。
* 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。
*/
val lastMessageAt: Long? = null,
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
val unreadCount: Int = 0,
/**
* 该会话绑定的角色 id(服务端 `personaId`,v0.42);null = 未绑(继承全局默认角色)。
* 只用来在列表行上回填角色名,见 [personaLabelFor]。
*/
val personaId: String? = null,
)
data class SessionsUiState(
val sessions: List<SessionItem> = emptyList(),
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
val loading: Boolean = false,
/** 当前展示的是本地快照(服务端没拉成功)。 */
val fromCache: Boolean = false,
val syncedAt: Long = 0L,
val error: String? = null,
/**
* 角色目录(`GET /personas`),渲染「新建会话」的角色弹层用;第一项恒是「默认角色」。
*
* 拿不到时它就是空的——弹层里只剩「默认角色」,新建会话照样能走完。这是**有意的降级**:
* 角色目录拿不到不该挡掉建会话。
*/
val personas: List<PersonaChoice> = emptyList(),
/** 角色目录**已经成功拉回过一次**。用来把「还没拉到」与「服务端就是没有角色」分开。 */
val personasLoaded: Boolean = false,
/** 角色目录拉取失败的原因。与会话列表的 [error] 分开:它只影响弹层里那几行,不动列表状态。 */
val personaError: String? = null,
)
/**
* 会话列表(§3.1):
* 启动先用本地快照渲染(指纹不匹配就不用),再拉服务端;成功则整体替换并回写快照,
* 失败则保留快照并提示。顺序完全信任服务端(`pinned DESC, main DESC, created_at ASC`),
* 客户端不重排。
*
* ## 未读(D4)
* 未读条数来自服务端(`GET /sessions` 的 `unreadCount`,恒下发),本类只在三处改它:
* 列表整表替换(以服务端为准)、**进入会话**、以及**正在看的会话有新消息落库**——后两处
* 同时调 `POST /sessions/{id}/read` 把游标推上去(策略详见 [pushReadCursor])。
* 反向同步(别的端读了)靠下行 `session_read` 帧。
*
* ## 为什么本类自己开一条 WS 连接
* `session_read` / `message_persisted` 都在 WS 上,而 `AppContainer` 只提供
* `createChatSocket()`(**每次调用新建**,没有进程级共享连接),`ChatSocket` / `ChatViewModel`
* 又都在别的改动范围里。要在这里收帧,唯一不改别人的办法就是自持一条连接。
* 代价如实说:这是本应用**已知遗留问题**(「每 VM 一条 socket、旧 socket 不关」,见
* `devdocs/05`)之上再加一条——服务端对同一账号的多连接是支持的(多端场景本来如此),
* 而且本类只关心两个帧类型,收不到也不会让功能失效(`GET /sessions` 会收敛)。
* 生命周期:登录后连接、登出断开、VM 销毁断开([onCleared])。
*/
class SessionsViewModel(private val container: AppContainer) : ViewModel() {
private val _state = MutableStateFlow(SessionsUiState())
val state: StateFlow<SessionsUiState> = _state.asStateFlow()
private val prefs = container.sessionPreferences
/**
* 本端**已知的已读游标**(会话 id → 毫秒),写入值一律取自**服务端口径**:
* - `POST /sessions/{id}/read` 响应里的 `readAt`(权威);
* - 下行 `session_read` 帧的 `readAt`(可能是别的端推的);
* - 本端对某条落库消息发起的推进,先用该消息的 `createdAt` 占位——它也是服务端时间,
* 所以不存在设备时钟偏差。
*
* 唯一用途是 [withReadCursors]:整表替换(REST 快照)时,把「本地已知没有比游标更新的
* 消息」的那些行的未读压回 0。不做这件事的话,刚在别处读完、或本端刚标记过的会话,
* 会因为一次竞态中的 GET 把角标又闪回来。
*/
private val readCursors = mutableMapOf<String, Long>()
private val socket: ChatSocket = container.createChatSocket()
init {
viewModelScope.launch {
val fingerprint = fingerprint()
val cached = prefs.data.first()
val snapshot = cached.snapshotFor(fingerprint)
_state.update {
it.copy(
sessions = snapshot.map { dto -> dto.toItem() }.withReadCursors(readCursors),
activeSessionId = if (snapshot.isEmpty()) {
SessionSummaryDto.MAIN_SESSION_ID
} else {
cached.activeSessionId
},
fromCache = snapshot.isNotEmpty(),
syncedAt = if (snapshot.isEmpty()) 0L else cached.snapshotAt,
)
}
refresh()
}
// 登出时把快照一起清掉(先例:EnumCatalogViewModel 在登出时清枚举快照)——
// 否则换账号后第一帧会闪一份上个账号的会话列表。已读游标同理:它是**账号级**的
// (服务端单一游标),留着会让新账号的会话被旧账号的游标错误地清成已读。
viewModelScope.launch {
container.authManager.state.collect { auth ->
when (auth) {
SessionState.LoggedOut -> {
socket.close()
readCursors.clear()
prefs.clear()
_state.value = SessionsUiState()
}
// 连接只在登录后建立:Socket 的令牌 / 地址都是每次重连现读的,
// connect() 幂等(已连接时直接返回)。本 VM 存活期跨越登出 → 重登,
// 所以「登出断开、重登接上」必须在这里显式表达。
is SessionState.LoggedIn -> socket.connect()
SessionState.Unknown -> Unit
}
}
}
// 未读的两个下行来源都在这里收敛;见 [onEvent]。
viewModelScope.launch { socket.events.collect(::onEvent) }
}
override fun onCleared() {
socket.close()
super.onCleared()
}
/**
* 拉角色目录(用户侧 `GET /personas`),供「新建会话」的角色弹层用。
*
* **跟着 [refresh] 走**:初始化 / 新建 / 删除 / 抽屉打开,与会话列表同一节奏——角色也可能在
* 管理侧被改名或增删,而那些改动**没有任何 WS 帧**会推过来,只能靠这里对齐。
*
* 失败**只记 [SessionsUiState.personaError]**,并**保留上一次拿到的列表**(一次网络抖动不该把
* 好数据擦掉);不碰会话列表的错误态——角色目录拿不到只影响「能不能选角色」,
* 用默认角色新建会话这条路照样通。
*/
private fun loadPersonas() {
viewModelScope.launch {
container.personaRepository.personas()
.onSuccess { list -> _state.update { it.withPersonaResult(loaded = list) } }
.onFailure { e -> _state.update { it.withPersonaResult(failure = e.userMessage()) } }
}
}
/**
* 拉取服务端列表。
*
* @param silent 静默刷新:**不动**“同步中…”横幅。用于界面上已经有内容、
* 只是去对齐服务端状态的场景(抽屉打开、改名/置顶之后),避免闪一下横幅。
*/
fun refresh(silent: Boolean = false) {
loadPersonas()
viewModelScope.launch {
if (!silent) _state.update { it.copy(loading = true) }
container.sessionRepository.listSessions()
.onSuccess { list ->
prefs.saveSnapshot(list, fingerprint())
val cached = prefs.data.first()
// 会话可能在**别处**被删掉(Web 面板 / 别的设备 / 直接调 API),而别处删除无法感知——
// 不校验 `activeSessionId` 就会一直指着一个已删会话,且这个陈旧 id 会被持久化下去。
val active = resolveActiveSession(_state.value.activeSessionId, list.map { it.id })
if (active != _state.value.activeSessionId) prefs.setActive(active)
_state.update {
it.copy(
// 未读以服务端为准,但先过一遍本地已知游标:一次竞态中的 GET
// (快照早于本端刚发起的 read)不该把角标闪回来。
sessions = list.map { dto -> dto.toItem() }.withReadCursors(readCursors),
activeSessionId = active,
loading = false,
fromCache = false,
syncedAt = cached.snapshotAt,
error = null,
)
}
}
.onFailure { e ->
_state.update {
it.copy(
loading = false,
fromCache = it.sessions.isNotEmpty(),
error = e.userMessage(),
)
}
}
}
}
/**
* 切换 / 进入会话。
*
* **进入即已读**(D4):用户点开这个会话,聊天页紧接着会加载并显示它的最新一页,
* 所以立刻把游标推到「现在」(`before` 缺席 = 服务端取现在)——这比推一个本地
* `lastMessage.createdAt` 更诚实:本地的列表可能已经陈旧,而聊天页看到的一定是最新的。
* 先就地清零(不等网络往返,角标立刻消失),再用响应里的权威游标收敛。
*
* `createSession` / `deleteSession` 也走这里,语义同样成立:那两个动作之后,
* 屏幕上(即将)显示的就是这个会话。
*/
fun select(sessionId: String) {
_state.update {
it.copy(activeSessionId = sessionId, sessions = it.sessions.markRead(sessionId))
}
viewModelScope.launch { prefs.setActive(sessionId) }
pushReadCursor(sessionId)
}
/**
* 新建会话;[personaId] 为 null 表示**「默认角色」**(不绑 personaId,跟随全局默认角色)。
*
* 角色**只在创建时**能定:服务端 `PATCH /sessions/{id}` 一旦收到 `personaId` 就回 `400`,
* 所以本端没有「会话内换角色」这个动作,选角只发生在这里。
*/
fun createSession(personaId: String? = null) {
viewModelScope.launch {
container.sessionRepository.createSession(personaId)
.onSuccess { sessionId ->
refresh()
select(sessionId)
}
.onFailure { e -> _state.update { it.copy(error = e.userMessage()) } }
}
}
fun deleteSession(sessionId: String) {
if (sessionId == SessionSummaryDto.MAIN_SESSION_ID) return
viewModelScope.launch {
container.sessionRepository.deleteSession(sessionId)
.onSuccess {
prefs.removeSession(sessionId)
if (_state.value.activeSessionId == sessionId) {
select(SessionSummaryDto.MAIN_SESSION_ID)
}
refresh()
}
.onFailure { e -> _state.update { it.copy(error = e.userMessage()) } }
}
}
/**
* 置顶 / 取消置顶。主会话也能置顶(契约允许)。
*
* 先就地翻转 `pinned` 拿到即时反馈,再发 PATCH;成功后**静默重拉**——
* 服务端才是顺序的权威(置顶会把会话排到最前),快照也顺带被修正。
* 失败则回滚到调用前的列表并报错。
*/
fun setPinned(sessionId: String, pinned: Boolean) {
val before = _state.value.sessions
_state.update { state ->
state.copy(
sessions = state.sessions.map { if (it.id == sessionId) it.copy(pinned = pinned) else it },
)
}
viewModelScope.launch {
container.sessionRepository.updateSession(sessionId, pinned = pinned)
.onSuccess { refresh(silent = true) }
.onFailure { e ->
_state.update { it.copy(sessions = before, error = e.userMessage()) }
}
}
}
/**
* 重命名。不做乐观更新:服务端会 trim 后回写,等静默重拉拿到权威标题。
* 调用方必须保证 [title] 非空白且不超过 80 个字符(服务端会 400)。
*/
fun renameSession(sessionId: String, title: String) {
viewModelScope.launch {
container.sessionRepository.updateSession(sessionId, title = title.trim())
.onSuccess { refresh(silent = true) }
.onFailure { e -> _state.update { it.copy(error = e.userMessage()) } }
}
}
fun dismissError() = _state.update { it.copy(error = null) }
/**
* 下行帧收敛(未读相关只有两类)。
*
* **这里刻意不用 [top.yeij.cyrene.data.ws.isForSession] 过滤**:那道闸是给「一个 VM 只关心
* 一个会话」的场景(`ChatViewModel`)用的,而本 VM 持有**整个会话列表**——按 activeSessionId
* 过滤会把「别的会话被读了」这一帧直接丢掉,正是最该处理的一半。所以改为按帧里的会话 id
* **逐行定位**([applyReadCursor],影响面严格限制在那一行)。
*/
private fun onEvent(event: WsEvent) {
when (event) {
// 别处(Web 面板 / 别的设备 / 本端另一次请求)读了:清掉那一行的红点。
is WsEvent.SessionRead -> onSessionRead(event)
// 正在看的会话落了新消息:消息已经显示在屏幕上了,立刻推进游标,
// 否则「自己正看着的会话」会攒出一个未读角标(契约 §3.9 的客户端处理建议)。
is WsEvent.MessagePersisted -> onMessagePersisted(event)
else -> Unit
}
}
private fun onSessionRead(event: WsEvent.SessionRead) {
if (event.sessionId.isBlank()) return
recordReadCursor(event.sessionId, event.readAt)
_state.update { it.copy(sessions = it.sessions.applyReadCursor(event.sessionId, event.readAt)) }
}
/**
* `message_persisted`:只有**当前打开的那个会话**才推进游标。
*
* 别的会话攒未读是正确行为(红点本来就该亮),所以这里不碰它们的计数——
* 本端只做「看到即已读」,不做「替服务端数未读」(增量计数会与编辑 / 删除 / 别端操作漂移,
* 而抽屉每次打开都会静默重拉,以服务端为准)。
*/
private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
if (sessionId != _state.value.activeSessionId) return
val message = event.message ?: return
// `createdAt` 是服务端时间:用它当 before 比「现在」更精确——只把**这条**及其之前标成
// 已读,同时落库的其它消息(时间更晚)仍算未读。缺时间戳时退化成「现在」,语义见 select()。
val before = message.createdAt.takeIf { it > 0 }
before?.let { recordReadCursor(sessionId, it) }
_state.update { it.copy(sessions = it.sessions.markRead(sessionId)) }
pushReadCursor(sessionId, before)
}
/**
* 推进已读游标(D4)。**失败静默**:这是一次后台的「顺手记一下」,不是用户发起的操作——
* 弹错误横幅会为一件用户没要求、也不影响使用的失败打扰他;而且服务端游标才是唯一真源,
* 漏标一次会在下一次 `GET /sessions` 暴露出来、再被 [select] / [onMessagePersisted] 纠正。
* 请求本身幂等(服务端 `MAX(read_at, before)`),重复发不会把已读退回未读。
*
* @param before null = 现在。**绝不能传 0**:服务端把 `0` / 负数当非法参数回 400。
*/
private fun pushReadCursor(sessionId: String, before: Long? = null) {
viewModelScope.launch {
container.sessionRepository.markRead(sessionId, before)
.onSuccess { response ->
if (response.readAt > 0) recordReadCursor(sessionId, response.readAt)
_state.update {
it.copy(sessions = it.sessions.applyReadCursor(sessionId, response.readAt))
}
}
}
}
/** 记下已知游标:只接受正数(坏帧的 `readAt = 0` 不能污染成「读到纪元」)。 */
private fun recordReadCursor(sessionId: String, readAt: Long) {
if (readAt <= 0) return
readCursors[sessionId] = maxOf(readCursors[sessionId] ?: 0L, readAt)
}
private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
}
internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
id = id,
title = displayTitle(),
isMain = isMain,
pinned = pinned,
lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() },
lastMessageAt = lastMessage?.createdAt?.takeIf { it > 0 },
unreadCount = unreadCount,
// 空串与缺省同义(契约:空/缺省 = 继承全局默认角色),统一收成 null——UI 上只有「绑没绑」两种。
personaId = personaId?.takeIf { it.isNotBlank() },
)
/**
* 未读角标的文本(纯函数)。`null` = **不显示**角标。
*
* `0` 与负数都不显示(服务端只会给非负整数,负数当数据错误处理,不画一个「-1」)。
* 超过 [UNREAD_BADGE_MAX] 收敛为 `99+`:三位数的角标在 300dp 的抽屉里会把标题挤没,
* 而三位数以上的精确值对「有多少条没看」几乎没有信息增量。
*/
internal fun unreadBadgeText(count: Int): String? = when {
count <= 0 -> null
count > UNREAD_BADGE_MAX -> "$UNREAD_BADGE_MAX+"
else -> count.toString()
}
/** 角标显示的未读上限;超过就显示 `99+`。 */
internal const val UNREAD_BADGE_MAX = 99
/** 整表替换(REST 快照)时,把本地已知已读的那些行压回 0 未读;见 `SessionsViewModel.readCursors`。 */
internal fun List<SessionItem>.withReadCursors(cursors: Map<String, Long>): List<SessionItem> =
map { item -> cursors[item.id]?.let { readAt -> item.withReadCursor(readAt) } ?: item }
/** 把某一行的未读清零(`session_read` 帧、或本端刚标记过)。 */
internal fun List<SessionItem>.applyReadCursor(sessionId: String, readAt: Long): List<SessionItem> =
map { if (it.id == sessionId) it.withReadCursor(readAt) else it }
/**
* 用户**正在看**这个会话(或本端刚为它推进过游标)时,无条件清掉它的未读。
* 与 [withReadCursor] 的区别:这里不需要游标依据——调用方已经知道「这一行就是屏幕上的那一行」
* (进入会话、正在看的会话落了新消息)。
*/
internal fun List<SessionItem>.markRead(sessionId: String): List<SessionItem> =
map { if (it.id == sessionId) it.copy(unreadCount = 0) else it }
/**
* 按已读游标清未读(纯函数):**只在「本地已知的最新一条消息不比游标新」时清零**。
*
* 为什么不能一律清零(api-client §3.9 说「直接清零也不会错」):那边的假设是「本地没有更完整的
* 数据」。本端有——`lastMessage.createdAt` 就是本地已知的最新消息时间。若它**晚于**游标,
* 说明确实还有未读(游标只前进,这只能是读完以后新到的消息),此时清零就是**把真实未读藏起来**。
* 保留旧计数更接近真相:精确条数本地算不出来(没有逐条时间线),等下次 `GET /sessions` 收敛。
*
* 会话没有消息([SessionItem.lastMessageAt] 为 null)时一定没有未读,清零。
*/
internal fun SessionItem.withReadCursor(readAt: Long): SessionItem {
val newest = lastMessageAt
return if (newest != null && newest > readAt) this else copy(unreadCount = 0)
}
/**
* 刷新成功后校验「当前会话」是否还在列表里;不在就回落到主会话。
*
* 什么时候会不在:会话在**别处**被删掉(Web 面板 / 别的设备 / 直接调 API)。
* 本端自己的删除已经处理过(`deleteSession` 会主动切回主会话),但**别处删除无法感知**——
* 不校验的话 `activeSessionId` 会一直指着一个已删会话:聊天页对着空会话,
* 而且这个陈旧 id 会被 `prefs.setActive` 持久化下去,重启后依然是它。
*
* 列表为空时**不回落**:那是异常响应,不该拿它去改用户的选择。
*/
internal fun resolveActiveSession(activeSessionId: String, fetchedIds: List<String>): String =
if (fetchedIds.isEmpty() || activeSessionId in fetchedIds) {
activeSessionId
} else {
SessionSummaryDto.MAIN_SESSION_ID
}
/**
* 列表行标题:优先用服务端的自动标题(首条用户消息生成),
* 会话还没有用户消息时 `title` 键整个缺席(或为空白),此时才兜底。
*
* 主会话兜底用「与昔涟」而不是服务端的「主会话」——这是个人应用,标题更贴切。
*/
fun SessionSummaryDto.displayTitle(): String = title?.takeIf { it.isNotBlank() }
?: if (isMain) MAIN_SESSION_TITLE else NEW_SESSION_TITLE
internal const val MAIN_SESSION_TITLE = "与昔涟"
internal const val NEW_SESSION_TITLE = "新会话"
/**
* 预览文本:服务端的 `content` 只折叠空白 + 截断到 60 字,`<p>` / `<action>` / `<narration>`
* 标记**原样保留**(截断还可能把标签截在半路),直接显示会露出标签,所以这里剥掉标签、折成一行。
*
* 注意标签替成**空格**而不是空串:旁白紧贴正文时(`…凑近了一点<action>悄悄戳了戳你</action>今天下午…`),
* 替成空串会把两侧的字粘成一个词。
*/
internal fun String.toPreviewText(): String = replace(PREVIEW_TAG_RE, " ")
.replace(WHITESPACE_RE, " ")
.trim()
/** 只剥本应用认识的三种标记;`\b` 保证 `<pre>` 这类同前缀标签不会被误伤。 */
private val PREVIEW_TAG_RE = Regex("</?\\s*(?:p|action|narration)\\b[^>]*>")
private val WHITESPACE_RE = Regex("\\s+")
private fun Throwable.userMessage(): String =
(this as? CyreneException)?.error?.userMessage ?: "操作失败,请稍后重试"
@@ -1,96 +0,0 @@
package top.yeij.cyrene.ui.settings
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 「关于」页:只有名称、版本、简介、作者这四项。
*
* 这四项分别来自本端常量与 `BuildConfig`(由 `app/build.gradle.kts` 生成),**不写死版本号**;
* 页面里刻意不放任何链接 / 邮箱 / 版权年份——没有人提供过这些信息,编不得。
*
* 与设置页同为聊天之上的独立层级:自己带 `Scaffold`,返回落到设置页(层级见 `ChatHomeScreen`)。
*/
@Composable
fun AboutScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
SmallTopAppBar(
title = "关于",
navigationIcon = {
IconButton(onClick = onBack) {
Icon(imageVector = MiuixIcons.ChevronBackward, contentDescription = "返回")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(40.dp))
Text("Cyrene", style = MiuixTheme.textStyles.title1)
Spacer(Modifier.height(4.dp))
Text(
text = "Cyrene v2 后端的 Android 客户端",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(32.dp))
AboutRow(title = "版本", value = versionText(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE))
Spacer(Modifier.height(8.dp))
AboutRow(title = "作者", value = "AskaEth")
Spacer(Modifier.height(32.dp))
}
}
}
@Composable
private fun AboutRow(title: String, value: String) {
PreferenceCard {
BasicComponent(
title = title,
endActions = {
Text(
text = value,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
},
)
}
}
/**
* 版本号的显示文本(纯函数):设置页的「版本」行与关于页共用同一份输出,格式不会各写各的。
*
* 值一律来自 `BuildConfig`(AGP 按 `build.gradle.kts` 的 `versionName` / `versionCode` 生成),
* 两处都不写死版本号,发版只需改构建文件。
*/
internal fun versionText(versionName: String, versionCode: Int): String = "$versionName ($versionCode)"
@@ -1,204 +0,0 @@
package top.yeij.cyrene.ui.settings
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.InterruptMode
import top.yeij.cyrene.core.prefs.ThemeMode
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Back
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun SettingsScreen(
container: AppContainer,
onBack: () -> Unit,
onLogout: () -> Unit = {},
onOpenAbout: () -> Unit = {},
) {
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
val scope = rememberCoroutineScope()
val settings = container.appSettings
Scaffold(
topBar = {
SmallTopAppBar(
title = "App 设置",
navigationIcon = {
IconButton(onClick = onBack) {
Icon(imageVector = MiuixIcons.ChevronBackward, contentDescription = "返回")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
SectionTitle("外观")
ThemeMode.entries.forEach { mode ->
PreferenceCard {
BasicComponent(
title = mode.label(),
onClick = { scope.launch { settings.setThemeMode(mode) } },
endActions = {
if (prefs.themeMode == mode) {
Text(
text = "✓",
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.primary,
)
}
},
)
}
Spacer(Modifier.height(8.dp))
}
SectionTitle("聊天")
PreferenceCard {
SwitchPreference(
title = "显示旁白",
summary = "以灰斜体小字显示 <action> / <narration>",
checked = prefs.showNarration,
onCheckedChange = { scope.launch { settings.setShowNarration(it) } },
)
}
Spacer(Modifier.height(8.dp))
PreferenceCard {
SwitchPreference(
title = "思维链默认展开",
summary = "流式期间展开思维链,结束后自动折叠",
checked = prefs.thinkingExpanded,
onCheckedChange = { scope.launch { settings.setThinkingExpanded(it) } },
)
}
SectionTitle("她忙时发消息")
InterruptMode.entries.forEach { mode ->
PreferenceCard {
BasicComponent(
title = mode.label(),
summary = mode.summary(),
onClick = { scope.launch { settings.setInterruptMode(mode) } },
endActions = {
if (prefs.interruptMode == mode) {
Text(
text = "✓",
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.primary,
)
}
},
)
}
Spacer(Modifier.height(8.dp))
}
SectionTitle("关于")
PreferenceCard {
BasicComponent(
title = "版本",
summary = null,
onClick = onOpenAbout,
endActions = {
Text(
text = versionText(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE),
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Icon(
imageVector = MiuixIcons.ChevronForward,
contentDescription = null,
modifier = Modifier.padding(start = 6.dp),
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
},
)
}
Spacer(Modifier.height(32.dp))
PreferenceCard {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onLogout) {
Icon(
imageVector = MiuixIcons.Back,
contentDescription = "登出",
tint = MiuixTheme.colorScheme.error,
)
}
}
}
Spacer(Modifier.height(32.dp))
}
}
}
@Composable
private fun SectionTitle(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),
)
}
private fun ThemeMode.label(): String = when (this) {
ThemeMode.System -> "跟随系统"
ThemeMode.Light -> "浅色"
ThemeMode.Dark -> "深色"
}
private fun InterruptMode.label(): String = when (this) {
InterruptMode.AppendQueue -> "追加队列"
InterruptMode.Interrupt -> "打断"
}
/**
* 两档的差别只有一处:**忙时那条排队消息还在不在**。文案必须说清这一点——
* 一个用户看不懂效果的偏好,比没有这个偏好更糟。
*/
private fun InterruptMode.summary(): String = when (this) {
InterruptMode.AppendQueue ->
"她正忙时只把消息排进队列,等她做完当前回复再依次回;不打断(默认)"
InterruptMode.Interrupt ->
"她正忙时先打断当前这一句;已排队的消息保留,继续依次回答"
}
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more