Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b8c8ab37d |
No files matched your search
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.**
|
||||
|
||||
-keepclassmembers class **$$serializer {
|
||||
*** descriptor;
|
||||
}
|
||||
-keepclasseswithmembers class ** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
@@ -1,26 +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">
|
||||
|
||||
<activity
|
||||
android:name=".app.MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,129 +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.MetaApi
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.repository.AdminRepository
|
||||
import top.yeij.cyrene.data.repository.ChatRepository
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.MetaRepository
|
||||
import top.yeij.cyrene.data.repository.SessionRepository
|
||||
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 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)
|
||||
|
||||
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,16 +0,0 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
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)
|
||||
val container = (application as CyreneApplication).container
|
||||
setContent {
|
||||
CyreneRoot(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,69 +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 }
|
||||
|
||||
/**
|
||||
* App 级偏好(非机密)。
|
||||
*
|
||||
* @param themeMode 主题深浅
|
||||
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
|
||||
* @param thinkingExpanded 思维链卡片默认是否展开
|
||||
*/
|
||||
data class AppPrefs(
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
val showNarration: Boolean = true,
|
||||
val thinkingExpanded: Boolean = true,
|
||||
)
|
||||
|
||||
/** 全局注入 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,
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
private fun String?.toThemeMode(): ThemeMode = when (this) {
|
||||
ThemeMode.Light.name -> ThemeMode.Light
|
||||
ThemeMode.Dark.name -> ThemeMode.Dark
|
||||
else -> ThemeMode.System
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
|
||||
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
|
||||
}
|
||||
}
|
||||
@@ -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,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,63 +0,0 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
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.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;服务端返回 `201 {data:{sessionId}}`。 */
|
||||
@POST("api/v1/sessions")
|
||||
suspend fun createSession(): 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>>>
|
||||
}
|
||||
@@ -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,59 +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])。
|
||||
*/
|
||||
@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,
|
||||
)
|
||||
|
||||
/**
|
||||
* `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,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,31 +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。
|
||||
*/
|
||||
@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,
|
||||
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,84 +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,
|
||||
) {
|
||||
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>"}}`。 */
|
||||
@Serializable
|
||||
data class CreateSessionResponse(
|
||||
val sessionId: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 会话最后一条消息的摘要(服务端 `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,
|
||||
)
|
||||
@@ -1,115 +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
|
||||
|
||||
/**
|
||||
* 把用户选的本地图片压成可以内联发送的 `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。
|
||||
*
|
||||
* 预算数学在 [ChatImageBudget](纯函数,可单测);本对象只剩「必须真机才有」的解码与编码。
|
||||
*/
|
||||
object ChatImagePreparer {
|
||||
|
||||
private const val JPEG_QUALITY = 80
|
||||
|
||||
/** 缩不动就减半重来的次数上限(1 MiB 预算下 1280px 起步一般 1–2 次就够)。 */
|
||||
private const val MAX_ATTEMPTS = 4
|
||||
|
||||
/**
|
||||
* @return 可直接作为 `image` 字段发送的 `data:` URI;失败返回 `null`。
|
||||
* 必须在后台线程调用(解码 + 缩放 + 编码都是重活)。
|
||||
*/
|
||||
fun prepare(context: Context, uri: Uri): String? {
|
||||
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): String? {
|
||||
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))
|
||||
return dataUri.takeIf { ChatImageBudget.withinServerLimit(it.length) }
|
||||
}
|
||||
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,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,52 +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.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>`)。 */
|
||||
suspend fun createSession(): Result<String> = withContext(dispatcher) {
|
||||
apiCall { api.createSession() }.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 }
|
||||
}
|
||||
}
|
||||
@@ -1,309 +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`(取消在跑 turn + 清空 pending)。 */
|
||||
fun sendInterrupt(sessionId: String): Boolean = send(WsOutbound.Interrupt(sessionId))
|
||||
|
||||
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,594 +0,0 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
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"
|
||||
}
|
||||
|
||||
/** 主会话(`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"
|
||||
}
|
||||
|
||||
/** 语音合成回复(定向回请求连接)。 */
|
||||
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)
|
||||
?: dispatchSession(type, turnId, seq, sessionId, data)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
"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,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PersistedData(val message: HistoryMessageDto? = null)
|
||||
|
||||
@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,124 +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}`。命中空闲会话为 no-op。 */
|
||||
data class Interrupt(
|
||||
val sessionId: String,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "interrupt"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject { put("sessionId", sessionId) }
|
||||
}
|
||||
|
||||
/** `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,60 +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
|
||||
|
||||
/** 当前角色可用的页,顺序与 [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,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,55 +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 上**必然**断连。
|
||||
*
|
||||
* ## 阈值
|
||||
* 判据是**文本的 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)
|
||||
*/
|
||||
fun channelFor(text: String, hasImage: Boolean): SendChannel = when {
|
||||
hasImage -> SendChannel.Rest
|
||||
utf8Length(text) <= WS_TEXT_LIMIT_BYTES -> SendChannel.Ws
|
||||
else -> SendChannel.Rest
|
||||
}
|
||||
}
|
||||
@@ -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,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,135 +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.ui.Modifier
|
||||
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.domain.admin.AdminFailure
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
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.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 概览页:`metrics` 卡片 + 其下的 `trace` 列表(两次请求,见 api-client §2「可观测性契约」)。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.overview.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Overview, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Overview.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
val data = state.data
|
||||
val failure = state.failure
|
||||
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 = "__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)
|
||||
}
|
||||
}
|
||||
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.perPath).ifBlank { "-" },
|
||||
"状态码" to formatCounts(metrics.statusCounts).ifBlank { "-" },
|
||||
)
|
||||
|
||||
@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,
|
||||
)
|
||||
}
|
||||
|
||||
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,114 +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.launch
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
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.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.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(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 管理只读监控的四页取数(api-client §2)。
|
||||
*
|
||||
* [load] 先按 [AdminRole] 短路:角色不够的页**不发请求**,直接给
|
||||
* [AdminFailure.Forbidden],UI 因此能显示「无权限」而不是等一个 403 回来。
|
||||
*/
|
||||
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()
|
||||
|
||||
/** 进入页面时调用;每次进入都重新取数(监控数据要新鲜)。 */
|
||||
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 即整页无权限,其它失败则保留已到的部分。 */
|
||||
private suspend fun loadOverview() {
|
||||
_overview.value = _overview.value.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() }
|
||||
_overview.value = AdminUiState(
|
||||
data = AdminOverviewData(
|
||||
metrics = metrics.getOrNull(),
|
||||
trace = trace.getOrNull().orEmpty(),
|
||||
),
|
||||
failure = failures.firstOrNull { it is AdminFailure.Forbidden } ?: failures.firstOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T> loadList(
|
||||
target: MutableStateFlow<AdminUiState<List<T>>>,
|
||||
fetch: suspend () -> Result<List<T>>,
|
||||
) {
|
||||
target.value = target.value.copy(loading = true, failure = null)
|
||||
fetch()
|
||||
.onSuccess { list -> target.value = AdminUiState(data = list) }
|
||||
.onFailure { e -> target.value = AdminUiState(failure = e.toAdminFailure()) }
|
||||
}
|
||||
|
||||
private fun deny(page: AdminPage) {
|
||||
when (page) {
|
||||
AdminPage.Overview -> _overview.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
AdminPage.Personas -> _personas.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
AdminPage.Memories -> _memories.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
AdminPage.Backthink -> _backthink.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +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.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) }
|
||||
|
||||
BackHandler(enabled = showAdmin) { showAdmin = false }
|
||||
BackHandler(enabled = showSettings) { showSettings = false }
|
||||
BackHandler(enabled = drawerOpen) { drawerOpen = false }
|
||||
|
||||
when {
|
||||
showAdmin -> AdminHomeScreen(
|
||||
container = container,
|
||||
onBack = { showAdmin = false },
|
||||
)
|
||||
|
||||
showSettings -> SettingsScreen(
|
||||
container = container,
|
||||
onBack = { showSettings = false },
|
||||
onLogout = onLogout,
|
||||
)
|
||||
|
||||
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,332 +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.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 top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
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.Image
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 判定「贴着底部」的容差(px)。 */
|
||||
private const val BOTTOM_TOLERANCE = 64
|
||||
|
||||
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
|
||||
private val InputBottomSpacer = 22.dp
|
||||
|
||||
@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()) } }
|
||||
|
||||
// 是否跟随底部(微信/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,
|
||||
busyText = busyLabel(state),
|
||||
onValueChange = viewModel::onDraftChange,
|
||||
onSend = viewModel::send,
|
||||
onPickImage = {
|
||||
imagePicker.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||
)
|
||||
},
|
||||
onClearImage = viewModel::clearDraftImage,
|
||||
)
|
||||
},
|
||||
) { 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let { message ->
|
||||
Text(
|
||||
text = message,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.background(MiuixTheme.colorScheme.surfaceContainer)
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputBar(
|
||||
value: String,
|
||||
imageUri: String?,
|
||||
busyText: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onPickImage: () -> Unit,
|
||||
onClearImage: () -> 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) }
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
|
||||
if (imageUri == null) {
|
||||
IconButton(onClick = onPickImage) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Image,
|
||||
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 至少一项非空)。
|
||||
enabled = value.isNotBlank() || imageUri != null,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 待发图片的本地预览(`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 -> ""
|
||||
}
|
||||
@@ -1,671 +0,0 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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 kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.ChatRequest
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.image.ChatImagePreparer
|
||||
import top.yeij.cyrene.data.repository.ChatAck
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.historyForSession
|
||||
import top.yeij.cyrene.data.repository.mergeMessages
|
||||
import top.yeij.cyrene.data.ws.ChatSocket
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
import top.yeij.cyrene.data.ws.isForSession
|
||||
import top.yeij.cyrene.domain.chat.ChatSendRouting
|
||||
import top.yeij.cyrene.domain.chat.SendChannel
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
import top.yeij.cyrene.domain.parse.StreamingContentParser
|
||||
import java.util.UUID
|
||||
|
||||
const val MAIN_SESSION_ID = "web"
|
||||
|
||||
/** 本端标识(design/16 §4.5 允许的可读前缀形态)。 */
|
||||
private const val DEVICE_ID = "voiceassist:cyrene:v1"
|
||||
|
||||
/**
|
||||
* 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。
|
||||
*
|
||||
* [localId] 同时是**上行幂等键** `clientMsgId`(契约 v0.56 §3.13):同一会话内服务端只承认
|
||||
* 第一个非空值,重复到达回 `chat_duplicate`(WS)或 `{duplicate:true,messageId}`(REST)而
|
||||
* **不再落库、不再起回合**。所以手动重试可以**复用**同一个 id,弱网重发不会变成第二条消息。
|
||||
* 它必须足够唯一:服务端的判重窗口是**整个会话历史**(键没有过期清理),进程重启后复用
|
||||
* 旧值会被当成重复、消息永远发不出去——因此用随机值而不是自增序号。
|
||||
*/
|
||||
data class PendingMessage(
|
||||
val localId: String,
|
||||
val text: String,
|
||||
/** 本次带的图片的**本地**预览(`content://` URI);null = 纯文本消息。 */
|
||||
val imageUri: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 落库的 user 消息到达时,该从待发列表里去掉哪一个乐观气泡(纯函数;返回新列表)。
|
||||
*
|
||||
* **首选 `clientMsgId`**(契约 v0.56 §3.13:带键发出的消息服务端会原样回填):键相等即同一条
|
||||
* 消息,**文本不同**也绝不会认错(发送时被 trim、服务端将来会再加工)。这是 v0.56 专门为
|
||||
* 「乐观气泡归一」加的通道,用来取代此前唯一的「同文本」匹配。
|
||||
*
|
||||
* 落库消息**没有键**时(老客户端发的、IM 镜像进来的、v0.56 之前的历史行)才回落到既有规则:
|
||||
* 去掉**第一条同文本**的气泡——服务端若是旧版本,永远只走这条路,行为与加入 REST 之前一致。
|
||||
*
|
||||
* 命中**恰好一条**且不修改入参:同一帧被重放、或两条同文本消息分别落库时,都不会一次吃掉两个气泡。
|
||||
*/
|
||||
fun consumePendingFor(
|
||||
pending: List<PendingMessage>,
|
||||
message: HistoryMessageDto,
|
||||
): List<PendingMessage> {
|
||||
val key = message.clientMsgId?.takeIf { it.isNotBlank() }
|
||||
val index = if (key != null) {
|
||||
pending.indexOfFirst { it.localId == key }
|
||||
} else {
|
||||
pending.indexOfFirst { it.text == message.content }
|
||||
}
|
||||
if (index < 0) return pending
|
||||
return pending.filterIndexed { i, _ -> i != index }
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具执行的细粒度阶段,与后端流程图 tool 节点 status **同一套枚举**(契约 v0.50 §3.1 / §3.5)。
|
||||
* `null` = 还没收到任何 `tool_execution.*` 帧(只有调用与参数)。
|
||||
*/
|
||||
enum class ToolPhase { Running, Done, Error }
|
||||
|
||||
/** 后端 `phase` 字符串 → 本地阶段;未知取值回落 `null`(不猜、不替服务端造枚举)。 */
|
||||
fun toolPhaseOf(phase: String): ToolPhase? = when (phase) {
|
||||
"running" -> ToolPhase.Running
|
||||
"done" -> ToolPhase.Done
|
||||
"error" -> ToolPhase.Error
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** 一次工具调用的中间态(参数增量累积 + 执行阶段 / 输出)。 */
|
||||
data class ToolCallState(
|
||||
val id: String,
|
||||
val index: Int,
|
||||
/** 工具名;`tool_call.*` 帧不带,由 `tool_execution.*` 帧补上。 */
|
||||
val tool: String = "",
|
||||
val arguments: String = "",
|
||||
val output: String = "",
|
||||
/** `tool_call.end` 已到(参数流完整)。 */
|
||||
val done: Boolean = false,
|
||||
/** 执行阶段;`null` = 尚未进入执行。 */
|
||||
val phase: ToolPhase? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 一帧 `tool_execution.*` 该落到 [tools] 的哪张卡上(纯函数;返回下标,`-1` = 不落位)。
|
||||
*
|
||||
* **关联键是 `id`**(provider 的 tool call id,与同轮 `tool_call.*` 同源;这三帧刻意不带
|
||||
* `index`——它们不属于任何一次模型流)。`id` 有值但不匹配任何卡 → `-1`:宁可丢这一帧的状态,
|
||||
* 也**不**错挂到别的工具上。
|
||||
*
|
||||
* **`id` 缺席时的回落**(后端在 provider 没给 id 时整个省略该键):只在**无歧义**时才落位——
|
||||
* 1. `id` 为空的工具卡恰好只有一张 → 落那张(id 缺席与 id 为空是同一件事);
|
||||
* 2. 否则整个列表恰好只有一张卡 → 落那张(这一轮只有一次工具调用,id 缺席不会认错);
|
||||
* 3. 其余情况(多张卡且无法唯一识别)→ `-1`,**不**默认落 `index == 0` / 第一张。
|
||||
*/
|
||||
fun toolExecutionTargetIndex(tools: List<ToolCallState>, id: String?): Int {
|
||||
val key = id?.takeIf { it.isNotBlank() }
|
||||
if (key != null) return tools.indexOfFirst { it.id == key }
|
||||
val anonymous = tools.indices.filter { tools[it].id.isBlank() }
|
||||
if (anonymous.size == 1) return anonymous.single()
|
||||
if (tools.size == 1) return 0
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一帧 `tool_execution.*` 应用到工具卡列表(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* 没找到落点时**原样返回**:不新建卡、不改别的卡。`tool` 为空白时保留卡上已有的名字,
|
||||
* `phase` 无法识别时保留卡上已有的阶段,`delta` 为空时不改输出。
|
||||
*/
|
||||
fun applyToolExecution(
|
||||
tools: List<ToolCallState>,
|
||||
id: String?,
|
||||
tool: String,
|
||||
phase: String,
|
||||
delta: String? = null,
|
||||
): List<ToolCallState> {
|
||||
val target = toolExecutionTargetIndex(tools, id)
|
||||
if (target < 0) return tools
|
||||
return tools.mapIndexed { index, state ->
|
||||
if (index != target) {
|
||||
state
|
||||
} else {
|
||||
state.copy(
|
||||
tool = tool.ifBlank { state.tool },
|
||||
phase = toolPhaseOf(phase) ?: state.phase,
|
||||
output = if (delta.isNullOrEmpty()) state.output else state.output + delta,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 正在流式的助手回复。 */
|
||||
data class StreamingState(
|
||||
val bubbles: List<ParsedBubble> = emptyList(),
|
||||
val thinking: String = "",
|
||||
val thinkingActive: Boolean = false,
|
||||
val tools: List<ToolCallState> = emptyList(),
|
||||
val active: Boolean = true,
|
||||
)
|
||||
|
||||
sealed interface ChatDisplayItem {
|
||||
val key: String
|
||||
|
||||
data class Committed(val message: HistoryMessageDto) : ChatDisplayItem {
|
||||
override val key: String get() = "m:${message.id}"
|
||||
}
|
||||
|
||||
data class Pending(val item: PendingMessage) : ChatDisplayItem {
|
||||
override val key: String get() = "p:${item.localId}"
|
||||
}
|
||||
|
||||
data object Streaming : ChatDisplayItem {
|
||||
override val key: String get() = "s"
|
||||
}
|
||||
}
|
||||
|
||||
data class ChatUiState(
|
||||
/** 已落库消息,升序(旧 → 新)。 */
|
||||
val messages: List<HistoryMessageDto> = emptyList(),
|
||||
val pending: List<PendingMessage> = emptyList(),
|
||||
val streaming: StreamingState? = null,
|
||||
val draft: String = "",
|
||||
/** 已选好、还没发出去的图片(本地 `content://` URI 字符串);null = 没有待发图片。 */
|
||||
val draftImage: String? = null,
|
||||
val loading: Boolean = true,
|
||||
val loadingOlder: Boolean = false,
|
||||
val hasMore: Boolean = true,
|
||||
val error: String? = null,
|
||||
val connection: WsState = WsState.Idle,
|
||||
/** 编排忙碌态:`idle`/`thinking`/`executing`/`subagent`;null = 空闲。 */
|
||||
val busyMode: String? = null,
|
||||
/**
|
||||
* 待处理队列深度。两个来源:WS 的 `pending_ack` / `pending_drain`,
|
||||
* 以及 REST `202 {queued:true,queueDepth:n}` 的受理回执(同一个队列,同一份语义)。
|
||||
*/
|
||||
val queueDepth: Int = 0,
|
||||
) {
|
||||
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
|
||||
val items: List<ChatDisplayItem>
|
||||
get() = buildList {
|
||||
messages.forEach { add(ChatDisplayItem.Committed(it)) }
|
||||
pending.forEach { add(ChatDisplayItem.Pending(it)) }
|
||||
if (streaming != null) add(ChatDisplayItem.Streaming)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M2 + M3 + M4:只读历史 + 发送 + 流式 + 思维链/工具卡。
|
||||
*
|
||||
* 数据来源四路,全部按 `id` 去重收敛:
|
||||
* 1. REST 首屏 / 上拉分页(权威,带游标)
|
||||
* 2. WS 首帧 `history`
|
||||
* 3. WS 增量 `message_persisted`(含乐观消息归一:按 `clientMsgId` 首选、同文本兜底)
|
||||
* 4. WS 流式 `thinking.* / tool_call.* / message.* / stream_end`(临时气泡,落库后由 3 取代)
|
||||
*
|
||||
* 上行两条通道(路由见 [ChatSendRouting]):短文本走 WS `chat` 帧,长文本 / 任何图片走
|
||||
* REST `POST /api/v1/chat`(`stream:true`)——**收都在 WS 上**,所以两种发的体验一致。
|
||||
*
|
||||
* 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅,
|
||||
* 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。
|
||||
*/
|
||||
class ChatViewModel(
|
||||
private val container: AppContainer,
|
||||
private val sessionId: String = MAIN_SESSION_ID,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(ChatUiState())
|
||||
val state: StateFlow<ChatUiState> = _state.asStateFlow()
|
||||
|
||||
private var socket: ChatSocket? = null
|
||||
private var repository: MessageRepository? = null
|
||||
private var oldestLoadedAt: Long? = null
|
||||
private var streamParser: StreamingContentParser? = null
|
||||
|
||||
init {
|
||||
start()
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
val repo = container.messageRepository
|
||||
repository = repo
|
||||
|
||||
viewModelScope.launch {
|
||||
repo.loadLatest(sessionId)
|
||||
.onSuccess { page ->
|
||||
oldestLoadedAt = page.oldestCreatedAt
|
||||
_state.update {
|
||||
it.copy(
|
||||
messages = mergeMessages(it.messages, page.messages),
|
||||
loading = false,
|
||||
hasMore = page.hasMore,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
_state.update { it.copy(loading = false, error = e.userMessage()) }
|
||||
}
|
||||
}
|
||||
|
||||
val ws = container.createChatSocket()
|
||||
socket = ws
|
||||
viewModelScope.launch {
|
||||
ws.state.collect { connection -> _state.update { it.copy(connection = connection) } }
|
||||
}
|
||||
viewModelScope.launch { ws.events.collect(::onEvent) }
|
||||
ws.connect()
|
||||
}
|
||||
|
||||
fun onDraftChange(text: String) = _state.update { it.copy(draft = text) }
|
||||
|
||||
/** 选好待发图片(本地 `content://` URI);传 null 或空白串即清除。 */
|
||||
fun onImagePicked(uri: String?) =
|
||||
_state.update { it.copy(draftImage = uri?.takeIf { value -> value.isNotBlank() }) }
|
||||
|
||||
fun clearDraftImage() = _state.update { it.copy(draftImage = null) }
|
||||
|
||||
/**
|
||||
* 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片还给输入框。
|
||||
*
|
||||
* 路由 [ChatSendRouting]:**短文本且无图 → WS**(直达、绕过有损总线,今天就能用);
|
||||
* **长文本或任何图片 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上流式
|
||||
* 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
|
||||
*
|
||||
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
|
||||
* 气泡同步上屏,所以等待期间用户已经能看到它。
|
||||
*/
|
||||
fun send() {
|
||||
val snapshot = _state.value
|
||||
val text = snapshot.draft.trim()
|
||||
val imageUri = snapshot.draftImage
|
||||
if (text.isEmpty() && imageUri == null) return
|
||||
|
||||
// localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。
|
||||
val localId = "local-${UUID.randomUUID()}"
|
||||
_state.update {
|
||||
it.copy(
|
||||
pending = it.pending + PendingMessage(localId, text, imageUri),
|
||||
draft = "",
|
||||
draftImage = null,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch { deliver(localId, text, imageUri) }
|
||||
}
|
||||
|
||||
private suspend fun deliver(localId: String, text: String, imageUri: String?) {
|
||||
val image = imageUri?.let { uri ->
|
||||
withContext(Dispatchers.IO) { ChatImagePreparer.prepare(container.appContext, Uri.parse(uri)) }
|
||||
}
|
||||
// 图片处理失败就**不发**:绝不退回原图 base64(会撞服务端 5 MiB 上限、也会把历史背成几 MB)。
|
||||
if (imageUri != null && image == null) {
|
||||
rollback(localId, "图片处理失败,请换一张")
|
||||
return
|
||||
}
|
||||
|
||||
when (ChatSendRouting.channelFor(text, hasImage = image != null)) {
|
||||
SendChannel.Ws -> {
|
||||
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false
|
||||
if (!sent) rollback(localId, "发送失败,请重试")
|
||||
}
|
||||
|
||||
SendChannel.Rest -> sendViaRest(localId, text, image)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendViaRest(localId: String, text: String, image: String?) {
|
||||
val request = ChatRequest(
|
||||
sessionId = sessionId,
|
||||
text = text,
|
||||
image = image,
|
||||
deviceId = DEVICE_ID,
|
||||
// 必须 true:`stream:false` 是同步的,服务端**没有写超时**而客户端读超时 60s——
|
||||
// 慢回合会在客户端先超时、服务端还在跑,重发就是第二次对话(契约明确禁用)。
|
||||
stream = true,
|
||||
clientMsgId = localId,
|
||||
)
|
||||
container.chatRepository
|
||||
.send(request)
|
||||
.onSuccess { ack -> onAck(localId, ack) }
|
||||
.onFailure { e -> rollback(localId, e.userMessage()) }
|
||||
}
|
||||
|
||||
/** REST 受理回执的六个分支(契约 v0.56 的响应面;形状见 [ChatAck])。 */
|
||||
private fun onAck(localId: String, ack: ChatAck) {
|
||||
when (ack) {
|
||||
// 200 {stream:true}:回合已起,气泡等 message_persisted 归一。
|
||||
ChatAck.Accepted -> Unit
|
||||
|
||||
// 202 {queued:true}:已**落库并续接排队**。轻量态交给既有忙碌标签(busyLabel 认
|
||||
// queueDepth);队列清空时 WS 的 `pending_drain` / `session_idle` 会把它归零。
|
||||
is ChatAck.Queued -> _state.update { it.copy(queueDepth = ack.queueDepth) }
|
||||
|
||||
// 幂等重复:服务端没有重复落库、也没有起第二个回合——对齐既有消息,**不是错误**。
|
||||
is ChatAck.Duplicate -> alignDuplicate(localId, ack.messageId)
|
||||
|
||||
// 409 队列满:这条**没有被受理、没有落库**(服务端在入队判定处直接拒绝),
|
||||
// 所以不能当成「已排队」——回滚气泡并把文本还回输入框,用的是既有忙碌文案的口径。
|
||||
ChatAck.QueueFull -> rollback(localId, "会话正忙,待处理消息已满,请稍后再发")
|
||||
|
||||
is ChatAck.Unavailable -> rollback(localId, "服务端暂时接不住这一轮,请稍后重试")
|
||||
|
||||
is ChatAck.Rejected -> rollback(localId, rejectMessage(ack))
|
||||
}
|
||||
}
|
||||
|
||||
private fun rejectMessage(rejected: ChatAck.Rejected): String = when (rejected.code) {
|
||||
"image_too_large" -> "图片过大,请换一张更小的"
|
||||
else -> rejected.message.ifBlank { "发送失败,请重试" }
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等重复的对齐(REST `{duplicate:true}` 与 WS `chat_duplicate` 共用):
|
||||
* 去掉那条乐观气泡;本地若**没有**这条消息,再补拉最新一页。
|
||||
*
|
||||
* 复用同一个 `clientMsgId` 重试时,正常路径本端早就收到过 `message_persisted`(第一次发的
|
||||
* 时候),但弱网 / 重连丢帧会留下「气泡没了、消息也没了」的空档——用 `messageId` 补一次
|
||||
* 增量拉取才是真的「对齐到服务端真相」,而不是让气泡凭空消失。
|
||||
*/
|
||||
private fun alignDuplicate(clientMsgId: String, messageId: String?) {
|
||||
_state.update { state ->
|
||||
state.copy(pending = state.pending.filterNot { it.localId == clientMsgId })
|
||||
}
|
||||
val known = messageId != null && _state.value.messages.any { it.id == messageId }
|
||||
if (messageId != null && !known) refreshLatest()
|
||||
}
|
||||
|
||||
/** 静默补拉最新一页(成功才并进来;失败不改状态——这只是一次对齐尝试)。 */
|
||||
private fun refreshLatest() {
|
||||
val repo = repository ?: return
|
||||
viewModelScope.launch {
|
||||
repo.loadLatest(sessionId).onSuccess { page ->
|
||||
page.oldestCreatedAt?.let { oldest -> oldestLoadedAt = minOf(oldestLoadedAt ?: oldest, oldest) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
messages = mergeMessages(it.messages, page.messages),
|
||||
pending = reconcilePending(it.pending, page.messages),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批**服务端权威消息**到达时,把它们对应的乐观气泡一并收掉(按 `clientMsgId`)。
|
||||
*
|
||||
* 通路除了 `message_persisted`,还有两条同样会带来「这条早就落库了」的路径:
|
||||
* WS 握手 `history` 回放(重连 / 冷启动)与 REST 补拉。少了这一步,
|
||||
* 「发出去 → 丢帧 → 重连回放」会留下**乐观气泡与落库消息同屏重复**。
|
||||
*/
|
||||
private fun reconcilePending(
|
||||
pending: List<PendingMessage>,
|
||||
incoming: List<HistoryMessageDto>,
|
||||
): List<PendingMessage> = incoming
|
||||
.filter { it.role == "user" }
|
||||
.fold(pending) { acc, message -> consumePendingFor(acc, message) }
|
||||
|
||||
/**
|
||||
* 回滚乐观气泡:把文本 / 图片还给输入框并给出原因(挂掉的气泡本身就是权威副本)。
|
||||
* 空文本(纯图片)不覆盖用户当前正在输入的内容;图片照原样还给待发位,不必让用户重挑。
|
||||
*/
|
||||
private fun rollback(localId: String, reason: String) {
|
||||
_state.update { state ->
|
||||
val failed = state.pending.firstOrNull { it.localId == localId }
|
||||
state.copy(
|
||||
pending = state.pending.filterNot { it.localId == localId },
|
||||
draft = failed?.text?.ifBlank { state.draft } ?: state.draft,
|
||||
draftImage = failed?.imageUri ?: state.draftImage,
|
||||
error = reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEvent(event: WsEvent) {
|
||||
// 广播是发给每条已认证连接的(服务端没有按会话订阅),所以每个 VM 都会收到**所有**会话的帧。
|
||||
// 帧壳自服务端 v0.36 起带 `sessionId`,先按它收敛:明确属于别的会话 → 整帧丢掉,
|
||||
// 否则上一会话的 VM 会拿新会话的 delta 跑自己的流式状态机(思维链/工具卡/正文串台)。
|
||||
// `history` 不走这道闸——它的归属由帧内**每条消息**的 sessionId 判定(见下面的
|
||||
// `historyForSession`),逐条过滤比帧壳更细,也更耐「将来服务端给次级会话回放」。
|
||||
if (event !is WsEvent.History && !event.isForSession(sessionId)) return
|
||||
|
||||
when (event) {
|
||||
is WsEvent.History -> {
|
||||
// 这一帧是**主会话**的回放,却会被投给任意 ChatViewModel——必须先收敛到本会话,
|
||||
// 否则进次级会话会看到主会话的消息混进来。
|
||||
val incoming = historyForSession(event.messages, sessionId)
|
||||
if (incoming.isEmpty()) {
|
||||
_state.update { it.copy(loading = false) }
|
||||
} else {
|
||||
val oldest = incoming.minOf { it.createdAt }
|
||||
oldestLoadedAt = oldestLoadedAt?.let { minOf(it, oldest) } ?: oldest
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
messages = mergeMessages(state.messages, incoming),
|
||||
pending = reconcilePending(state.pending, incoming),
|
||||
loading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// —— 流式正文 ——
|
||||
is WsEvent.MessageStart -> {
|
||||
if (event.index == 0) {
|
||||
streamParser = StreamingContentParser()
|
||||
freshTurnIfFinished()
|
||||
}
|
||||
val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
|
||||
val bubbles = parser.onDelta("")
|
||||
updateStreaming { it.copy(bubbles = bubbles, active = true) }
|
||||
}
|
||||
|
||||
is WsEvent.MessageDelta -> {
|
||||
val parser = streamParser ?: StreamingContentParser().also { streamParser = it }
|
||||
val bubbles = parser.onDelta(event.delta)
|
||||
updateStreaming { it.copy(bubbles = bubbles, active = true) }
|
||||
}
|
||||
|
||||
// —— 思维链 ——
|
||||
is WsEvent.ThinkingStart -> {
|
||||
if (event.index == 0) freshTurnIfFinished()
|
||||
updateStreaming { it.copy(thinkingActive = true) }
|
||||
}
|
||||
|
||||
is WsEvent.ThinkingDelta -> updateStreaming {
|
||||
it.copy(thinking = it.thinking + event.delta, thinkingActive = true)
|
||||
}
|
||||
|
||||
is WsEvent.ThinkingEnd -> updateStreaming { it.copy(thinkingActive = false) }
|
||||
|
||||
// —— 工具调用 ——
|
||||
is WsEvent.ToolCallStart -> updateStreaming { streaming ->
|
||||
if (streaming.tools.any { it.id == event.id }) {
|
||||
streaming
|
||||
} else {
|
||||
streaming.copy(tools = streaming.tools + ToolCallState(event.id, event.index))
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.ToolCallDelta -> updateStreaming { streaming ->
|
||||
streaming.copy(
|
||||
tools = streaming.tools.map {
|
||||
if (it.id == event.id) it.copy(arguments = it.arguments + event.delta) else it
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is WsEvent.ToolCallEnd -> updateStreaming { streaming ->
|
||||
streaming.copy(
|
||||
tools = streaming.tools.map { if (it.id == event.id) it.copy(done = true) else it },
|
||||
)
|
||||
}
|
||||
|
||||
is WsEvent.ToolExecutionStart -> onToolExecution(event.id, event.tool, event.phase)
|
||||
|
||||
is WsEvent.ToolExecutionUpdate ->
|
||||
onToolExecution(event.id, event.tool, event.phase, event.delta)
|
||||
|
||||
is WsEvent.ToolExecutionEnd -> onToolExecution(event.id, event.tool, event.phase)
|
||||
|
||||
is WsEvent.StreamEnd -> {
|
||||
val bubbles = streamParser?.finish().orEmpty()
|
||||
updateStreaming {
|
||||
it.copy(bubbles = bubbles, thinkingActive = false, active = false)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.MessagePersisted -> onPersisted(event.message)
|
||||
|
||||
is WsEvent.SessionBusy -> if (event.sessionId == sessionId) {
|
||||
_state.update { it.copy(busyMode = event.mode) }
|
||||
}
|
||||
|
||||
is WsEvent.PendingAck -> if (event.sessionId == sessionId) {
|
||||
_state.update { it.copy(busyMode = event.mode, queueDepth = event.queueDepth) }
|
||||
}
|
||||
|
||||
is WsEvent.PendingDrain -> if (event.sessionId == sessionId) {
|
||||
_state.update { it.copy(queueDepth = event.queueDepth) }
|
||||
}
|
||||
|
||||
// 幂等重复(契约 v0.56 §3.13):这条消息**早已受理过**,本端重发(弱网 / 切后台)撞上了
|
||||
// 服务端的部分唯一索引。它不是错误——按既有 messageId 对齐气泡即可,绝不弹报错。
|
||||
is WsEvent.ChatDuplicate -> if (event.sessionId == sessionId) {
|
||||
alignDuplicate(event.clientMsgId, event.messageId)
|
||||
}
|
||||
|
||||
is WsEvent.SessionIdle -> if (event.sessionId == sessionId) {
|
||||
_state.update { it.copy(busyMode = null, queueDepth = 0) }
|
||||
}
|
||||
|
||||
is WsEvent.SessionInterrupted -> if (event.sessionId == sessionId) {
|
||||
_state.update { it.copy(busyMode = null, queueDepth = 0) }
|
||||
}
|
||||
|
||||
is WsEvent.Error -> _state.update { it.copy(error = event.message) }
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** 新一轮开始时,若上一轮的流式状态已结束(active=false)就清掉,避免思维链/工具卡串台。 */
|
||||
private fun freshTurnIfFinished() {
|
||||
_state.update { state ->
|
||||
val streaming = state.streaming
|
||||
if (streaming != null && !streaming.active) state.copy(streaming = StreamingState()) else state
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStreaming(block: (StreamingState) -> StreamingState) {
|
||||
_state.update { state ->
|
||||
state.copy(streaming = block(state.streaming ?: StreamingState()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收口三帧 `tool_execution.*`:按 `id` 关联到对应的工具卡(关联规则见
|
||||
* [toolExecutionTargetIndex]),落 phase / 工具名 / 局部输出。落不到就整帧忽略。
|
||||
*/
|
||||
private fun onToolExecution(id: String?, tool: String, phase: String, delta: String? = null) {
|
||||
updateStreaming { streaming ->
|
||||
streaming.copy(tools = applyToolExecution(streaming.tools, id, tool, phase, delta))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 落库消息到达:
|
||||
* - user:把对应的乐观气泡去掉——**首选 `clientMsgId`**(v0.56 起服务端回填),
|
||||
* 没有键时才回落到「同文本」匹配(见 [consumePendingFor])
|
||||
* - assistant:用落库内容取代流式临时气泡
|
||||
*/
|
||||
private fun onPersisted(message: HistoryMessageDto?) {
|
||||
val msg = message ?: return
|
||||
if (msg.sessionId != sessionId) return
|
||||
|
||||
val clearStreaming = msg.role == "assistant"
|
||||
// 流式结束:把这一轮的思维链挂到落库消息上,保留 10 分钟后再自动消失
|
||||
val streamedThinking = if (clearStreaming) {
|
||||
_state.value.streaming?.thinking?.takeIf { it.isNotBlank() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val persisted = if (streamedThinking != null) msg.copy(thinking = streamedThinking) else msg
|
||||
if (clearStreaming) streamParser = null
|
||||
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
messages = mergeMessages(state.messages, listOf(persisted)),
|
||||
pending = if (msg.role == "user") consumePendingFor(state.pending, msg) else state.pending,
|
||||
streaming = if (clearStreaming) null else state.streaming,
|
||||
)
|
||||
}
|
||||
|
||||
if (streamedThinking != null) {
|
||||
viewModelScope.launch {
|
||||
kotlinx.coroutines.delay(THINKING_LINGER_MILLIS)
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
messages = state.messages.map {
|
||||
if (it.id == persisted.id) it.copy(thinking = null) else it
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 上拉加载更早的一页。 */
|
||||
fun loadOlder() {
|
||||
val repo = repository ?: return
|
||||
val before = oldestLoadedAt ?: return
|
||||
val snapshot = _state.value
|
||||
if (snapshot.loadingOlder || !snapshot.hasMore) return
|
||||
|
||||
_state.update { it.copy(loadingOlder = true) }
|
||||
viewModelScope.launch {
|
||||
repo.loadPage(sessionId, before = before)
|
||||
.onSuccess { page ->
|
||||
if (page.oldestCreatedAt != null) oldestLoadedAt = page.oldestCreatedAt
|
||||
_state.update {
|
||||
it.copy(
|
||||
messages = mergeMessages(it.messages, page.messages),
|
||||
loadingOlder = false,
|
||||
hasMore = page.hasMore && page.messages.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
_state.update { it.copy(loadingOlder = false, error = e.userMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() = _state.update { it.copy(error = null) }
|
||||
|
||||
override fun onCleared() {
|
||||
socket?.close()
|
||||
socket = null
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.userMessage(): String =
|
||||
(this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试"
|
||||
|
||||
/** 思维链在流式结束后继续保留的时长。 */
|
||||
private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L
|
||||
@@ -1,199 +0,0 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
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.Text
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 流式进行中的光标。 */
|
||||
private const val CURSOR = "▍"
|
||||
|
||||
/**
|
||||
* 一条落库消息渲染成一个或多个气泡:
|
||||
* - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `<p>` 切分,
|
||||
* `<action>` / `<narration>` 解析成灰斜体小字,依附到所属气泡的上方/下方;
|
||||
* - 其它 mode 原样整条渲染,不解析标签;
|
||||
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
|
||||
* - `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) }
|
||||
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 = "发送中…") {
|
||||
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
|
||||
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
|
||||
}
|
||||
}
|
||||
|
||||
/** 正在流式的助手回复:思维链 → 工具卡 → 气泡(末尾带光标)。 */
|
||||
@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,308 +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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.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 小圆角卡片。
|
||||
*
|
||||
* 行内交互:单击切换会话、**长按重命名**、行尾置顶/取消置顶;删除只对次级会话开放。
|
||||
* 标题是服务端的自动标题(首条用户消息生成),没有就落到兜底标题。
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.SessionDrawerContent(
|
||||
state: SessionsUiState,
|
||||
onSelect: (String) -> Unit,
|
||||
onCreate: () -> 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) }
|
||||
|
||||
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 = onCreate) {
|
||||
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,
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按,
|
||||
* `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable,
|
||||
* 子级会先吃掉按下事件,长按就永远不会触发)。
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionRow(
|
||||
session: SessionItem,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onTogglePin: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onRename),
|
||||
title = session.title,
|
||||
summary = session.lastMessage,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 重命名对话框:服务端不接受空标题(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,245 +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
|
||||
|
||||
data class SessionItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val isMain: Boolean,
|
||||
val pinned: Boolean,
|
||||
/** 最后一条消息的一行摘要;会话还没有消息时为 null。 */
|
||||
val lastMessage: String?,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
* 会话列表(§3.1):
|
||||
* 启动先用本地快照渲染(指纹不匹配就不用),再拉服务端;成功则整体替换并回写快照,
|
||||
* 失败则保留快照并提示。顺序完全信任服务端(`pinned DESC, main DESC, created_at ASC`),
|
||||
* 客户端不重排。
|
||||
*/
|
||||
class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(SessionsUiState())
|
||||
val state: StateFlow<SessionsUiState> = _state.asStateFlow()
|
||||
|
||||
private val prefs = container.sessionPreferences
|
||||
|
||||
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() },
|
||||
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 ->
|
||||
if (auth == SessionState.LoggedOut) {
|
||||
prefs.clear()
|
||||
_state.value = SessionsUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取服务端列表。
|
||||
*
|
||||
* @param silent 静默刷新:**不动**“同步中…”横幅。用于界面上已经有内容、
|
||||
* 只是去对齐服务端状态的场景(抽屉打开、改名/置顶之后),避免闪一下横幅。
|
||||
*/
|
||||
fun refresh(silent: Boolean = false) {
|
||||
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(
|
||||
sessions = list.map { dto -> dto.toItem() },
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun select(sessionId: String) {
|
||||
_state.update { it.copy(activeSessionId = sessionId) }
|
||||
viewModelScope.launch { prefs.setActive(sessionId) }
|
||||
}
|
||||
|
||||
fun createSession() {
|
||||
viewModelScope.launch {
|
||||
container.sessionRepository.createSession()
|
||||
.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) }
|
||||
|
||||
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() },
|
||||
)
|
||||
|
||||
/**
|
||||
* 刷新成功后校验「当前会话」是否还在列表里;不在就回落到主会话。
|
||||
*
|
||||
* 什么时候会不在:会话在**别处**被删掉(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,157 +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.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.preference.SwitchPreference
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
container: AppContainer,
|
||||
onBack: () -> Unit,
|
||||
onLogout: () -> 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("关于")
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
title = "版本",
|
||||
summary = null,
|
||||
endActions = {
|
||||
Text(
|
||||
text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = 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 -> "深色"
|
||||
}
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 72 KiB |
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Cyrene" parent="@android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/background_dark</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#F8F8F8</color>
|
||||
</resources>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Cyrene</string>
|
||||
</resources>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Cyrene" parent="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/background_light</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
自部署场景:用户会把 App 指向自己局域网/本机的 HTTP 服务(如 http://192.168.x.x:8080、
|
||||
http://127.0.0.1:10942),所以必须放行明文流量;同时信任用户安装的 CA,
|
||||
便于自签证书的 HTTPS 部署。生产公网部署仍建议用 HTTPS。
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -1,406 +0,0 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
import top.yeij.cyrene.data.api.CyreneApi
|
||||
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 java.io.IOException
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* B2 迁移与登出的行为钉死。
|
||||
*
|
||||
* 迁移的目标(brief §9):**升级对用户不可见**——不强制重登、不丢会话,
|
||||
* 但首次成功认证之后任何安装都不再留着明文密码。
|
||||
*
|
||||
* 全部走内存假存储 + 假 [CyreneApi],所以既不用网络,也不用新增依赖。
|
||||
* 夹具里的用户名/密码一律是中性值,真实凭据不进仓库。
|
||||
*/
|
||||
class AuthManagerBootstrapTest {
|
||||
|
||||
private val baseUrl = "http://127.0.0.1:10942/"
|
||||
private val username = "testuser"
|
||||
private val legacyPassword = "legacy-secret"
|
||||
private val nowSeconds = System.currentTimeMillis() / 1000
|
||||
|
||||
private val newToken = "jwt-new"
|
||||
private val newRefreshToken = "refresh-new"
|
||||
|
||||
// —— 迁移 ——
|
||||
|
||||
@Test
|
||||
fun `legacy password is exchanged for a refresh token and then wiped`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = null, expiresAt = 0, refreshToken = null, password = legacyPassword),
|
||||
)
|
||||
val api = FakeCyreneApi(
|
||||
loginResult = {
|
||||
Response.success(
|
||||
ApiEnvelope(
|
||||
data = LoginResponse(
|
||||
token = newToken,
|
||||
expiresAt = nowSeconds + 86_400,
|
||||
role = "owner",
|
||||
refreshToken = newRefreshToken,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
|
||||
assertEquals(1, api.loginRequests.size)
|
||||
assertEquals(username, api.loginRequests.single().username)
|
||||
assertEquals(legacyPassword, api.loginRequests.single().password)
|
||||
assertEquals(newToken, store.load().token)
|
||||
assertEquals(newRefreshToken, store.load().refreshToken)
|
||||
assertNull("迁移成功后必须立刻抹掉明文密码", store.load().password)
|
||||
assertEquals("username 必须留着(登录页与缓存指纹都靠它)", username, store.load().username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy login failure keeps the legacy values for the next launch`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = null, expiresAt = 0, refreshToken = null, password = legacyPassword),
|
||||
)
|
||||
val api = FakeCyreneApi(loginResult = { throw IOException("offline") })
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
// 没网也不能吞掉凭据:下次启动还要再试一次。
|
||||
assertEquals(legacyPassword, store.load().password)
|
||||
assertEquals(username, store.load().username)
|
||||
assertNull(store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a still-valid access token survives a failed legacy migration without logging out`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(
|
||||
token = "jwt-still-valid",
|
||||
expiresAt = nowSeconds + 3_600,
|
||||
refreshToken = null,
|
||||
password = legacyPassword,
|
||||
),
|
||||
)
|
||||
val api = FakeCyreneApi(loginResult = { throw IOException("offline") })
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
// 关键:手里还有有效令牌时,迁移失败**绝不能让用户掉到登录页**。
|
||||
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
|
||||
assertEquals("jwt-still-valid", store.load().token)
|
||||
assertEquals(legacyPassword, store.load().password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leftover password is wiped when a refresh token is already present`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(
|
||||
token = "jwt-valid",
|
||||
expiresAt = nowSeconds + 3_600,
|
||||
refreshToken = "refresh-existing",
|
||||
password = legacyPassword,
|
||||
),
|
||||
)
|
||||
val api = FakeCyreneApi()
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
|
||||
assertEquals(0, api.loginRequests.size)
|
||||
assertNull(store.load().password)
|
||||
assertEquals("refresh-existing", store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing stored at all lands on the login screen`() = runBlocking {
|
||||
val store = FakeSessionStorage(StoredSession(baseUrl, null, 0, null, null, null, null))
|
||||
val manager = AuthManager(store) { FakeCyreneApi() }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
}
|
||||
|
||||
// —— 令牌过期后的静默恢复 ——
|
||||
|
||||
@Test
|
||||
fun `an expired access token is renewed with the refresh token at bootstrap`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-existing"),
|
||||
)
|
||||
val api = FakeCyreneApi(
|
||||
refreshResult = {
|
||||
Response.success(
|
||||
ApiEnvelope(
|
||||
data = RefreshResponse(
|
||||
token = newToken,
|
||||
expiresAt = nowSeconds + 86_400,
|
||||
role = "owner",
|
||||
refreshToken = newRefreshToken,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
|
||||
assertEquals(1, api.refreshRequests.size)
|
||||
assertEquals("refresh-existing", api.refreshRequests.single().refreshToken)
|
||||
assertEquals(newToken, store.load().token)
|
||||
assertEquals(newRefreshToken, store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired token with a rejected refresh token forces a re-login`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-dead"),
|
||||
)
|
||||
val api = FakeCyreneApi(
|
||||
refreshResult = {
|
||||
Response.error(401, """{"error":{"code":"invalid_grant"}}""".asErrorBody())
|
||||
},
|
||||
)
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
assertEquals(1, api.refreshRequests.size)
|
||||
}
|
||||
|
||||
// —— 登出 ——
|
||||
|
||||
@Test
|
||||
fun `logout revokes the server-side grant before clearing locally`() = runBlocking {
|
||||
val store = FakeSessionStorage(session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = "refresh-1"))
|
||||
val api = FakeCyreneApi()
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.logout()
|
||||
|
||||
assertEquals(1, api.logoutRequests.size)
|
||||
assertEquals("refresh-1", api.logoutRequests.single().refreshToken)
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
assertNull(store.load().token)
|
||||
assertNull(store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an offline logout still clears everything locally`() = runBlocking {
|
||||
val store = FakeSessionStorage(session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = "refresh-1"))
|
||||
val api = FakeCyreneApi(logoutResult = { throw IOException("offline") })
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.logout()
|
||||
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
assertNull(store.load().refreshToken)
|
||||
assertNull(store.load().token)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `logout without a refresh token does not even call the server`() = runBlocking {
|
||||
val store = FakeSessionStorage(session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = null))
|
||||
val api = FakeCyreneApi()
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.logout()
|
||||
|
||||
assertEquals(0, api.logoutRequests.size)
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
}
|
||||
|
||||
// —— 终态 ——
|
||||
|
||||
@Test
|
||||
fun `terminal session loss clears the tokens but keeps the username`() {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = "refresh-1"),
|
||||
)
|
||||
val manager = AuthManager(store) { FakeCyreneApi() }
|
||||
|
||||
manager.onSessionExpired()
|
||||
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
assertNull(store.load().token)
|
||||
assertNull(store.load().refreshToken)
|
||||
assertEquals(username, store.load().username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient refresh failure never ends the session`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-1"),
|
||||
)
|
||||
val api = FakeCyreneApi(refreshResult = { throw IOException("offline") })
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
// 拦截器只在终态才回调 onSessionEnded,所以这里只验证策略本身把两种失败分开了。
|
||||
assertEquals(AuthRecovery.Transient, manager.unauthorizedPolicy.renew("jwt-expired"))
|
||||
assertEquals("refresh-1", store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the unauthorized policy reports terminal loss only for a rejected credential`() {
|
||||
val terminalStore = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-1"),
|
||||
)
|
||||
val terminalApi = FakeCyreneApi(
|
||||
refreshResult = { Response.error(401, """{"error":{"code":"invalid_grant"}}""".asErrorBody()) },
|
||||
)
|
||||
val terminalManager = AuthManager(terminalStore) { terminalApi }
|
||||
assertEquals(AuthRecovery.Unrecoverable, terminalManager.unauthorizedPolicy.renew("jwt-expired"))
|
||||
|
||||
// 没有 refresh token 可用 → 同样是终态。
|
||||
val noCredentialStore = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = null),
|
||||
)
|
||||
val noCredentialManager = AuthManager(noCredentialStore) { FakeCyreneApi() }
|
||||
assertEquals(AuthRecovery.Unrecoverable, noCredentialManager.unauthorizedPolicy.renew("jwt-expired"))
|
||||
|
||||
// 续期成功 → 重试用的新令牌就是刷新回来的那一个。
|
||||
val okStore = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-1"),
|
||||
)
|
||||
val okApi = FakeCyreneApi(
|
||||
refreshResult = {
|
||||
Response.success(ApiEnvelope(data = RefreshResponse(newToken, nowSeconds + 86_400, "owner", null)))
|
||||
},
|
||||
)
|
||||
val okManager = AuthManager(okStore) { okApi }
|
||||
val recovered = okManager.unauthorizedPolicy.renew("jwt-expired")
|
||||
assertTrue(recovered is AuthRecovery.Renewed)
|
||||
assertEquals(newToken, (recovered as AuthRecovery.Renewed).token)
|
||||
}
|
||||
|
||||
// —— 夹具 ——
|
||||
|
||||
private fun session(
|
||||
token: String?,
|
||||
expiresAt: Long,
|
||||
refreshToken: String?,
|
||||
password: String? = null,
|
||||
) = StoredSession(
|
||||
baseUrl = baseUrl,
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
role = "owner",
|
||||
username = username,
|
||||
refreshToken = refreshToken,
|
||||
password = password,
|
||||
)
|
||||
|
||||
private fun String.asErrorBody(): ResponseBody = toResponseBody("application/json".toMediaType())
|
||||
|
||||
private class FakeSessionStorage(initial: StoredSession) : SessionStorage {
|
||||
|
||||
@Volatile
|
||||
private var session: StoredSession = initial
|
||||
|
||||
override fun load(): StoredSession = session
|
||||
|
||||
override fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
) {
|
||||
session = session.copy(
|
||||
baseUrl = baseUrl,
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
role = role,
|
||||
username = username,
|
||||
refreshToken = refreshToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
|
||||
session = session.copy(
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
refreshToken = refreshToken ?: session.refreshToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun purgePassword() {
|
||||
session = session.copy(password = null)
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
session = session.copy(token = null, expiresAt = 0, role = null, refreshToken = null)
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
session = StoredSession("", null, 0, null, null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** 假的认证接口:三个端点各自可替换返回,并记录收到的请求体。 */
|
||||
private class FakeCyreneApi(
|
||||
private val loginResult: (LoginRequest) -> Response<ApiEnvelope<LoginResponse>> = {
|
||||
Response.success(
|
||||
ApiEnvelope(
|
||||
data = LoginResponse("jwt-default", System.currentTimeMillis() / 1000 + 86_400, "owner", null),
|
||||
),
|
||||
)
|
||||
},
|
||||
private val refreshResult: (RefreshRequest) -> Response<ApiEnvelope<RefreshResponse>> = {
|
||||
Response.success(
|
||||
ApiEnvelope(
|
||||
data = RefreshResponse("jwt-refreshed", System.currentTimeMillis() / 1000 + 86_400, "owner", null),
|
||||
),
|
||||
)
|
||||
},
|
||||
private val logoutResult: (LogoutRequest) -> Response<ApiEnvelope<LogoutResponse>> = {
|
||||
Response.success(ApiEnvelope(data = LogoutResponse(revoked = true)))
|
||||
},
|
||||
) : CyreneApi {
|
||||
|
||||
val loginRequests = CopyOnWriteArrayList<LoginRequest>()
|
||||
val refreshRequests = CopyOnWriteArrayList<RefreshRequest>()
|
||||
val logoutRequests = CopyOnWriteArrayList<LogoutRequest>()
|
||||
|
||||
override suspend fun login(body: LoginRequest): Response<ApiEnvelope<LoginResponse>> {
|
||||
loginRequests += body
|
||||
return loginResult(body)
|
||||
}
|
||||
|
||||
override suspend fun refresh(body: RefreshRequest): Response<ApiEnvelope<RefreshResponse>> {
|
||||
refreshRequests += body
|
||||
return refreshResult(body)
|
||||
}
|
||||
|
||||
override suspend fun logout(body: LogoutRequest): Response<ApiEnvelope<LogoutResponse>> {
|
||||
logoutRequests += body
|
||||
return logoutResult(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* 单飞刷新与代际守卫的行为钉死(B2 §4/§5)。
|
||||
*
|
||||
* 服务端的轮换是**原子且无宽限期**的:同一个 refresh token 被第二次使用立刻 `invalid_grant`,
|
||||
* 所以「并发 401 只刷一次」不是优化而是正确性要求——这些用例就是它的证据。
|
||||
*/
|
||||
class TokenRefresherTest {
|
||||
|
||||
private val nowMs = 1_791_000_000_000L
|
||||
private val nowSeconds = nowMs / 1000
|
||||
|
||||
private val staleToken = "jwt-stale"
|
||||
private val freshToken = "jwt-fresh"
|
||||
private val staleRefreshToken = "refresh-stale"
|
||||
private val rotatedRefreshToken = "refresh-rotated"
|
||||
|
||||
// —— 用例 ——
|
||||
|
||||
@Test
|
||||
fun `concurrent callers sharing one stale token perform exactly one refresh`() = runBlocking {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { _ ->
|
||||
calls.incrementAndGet()
|
||||
// 制造重叠窗口:其余调用方要么正在锁上等,要么已经排在门口。
|
||||
Thread.sleep(50)
|
||||
RefreshCall.Ok(freshToken, nowSeconds + 86_400, rotatedRefreshToken)
|
||||
},
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
val results = (1..8)
|
||||
.map { async(Dispatchers.IO) { refresher.renew(staleToken) } }
|
||||
.awaitAll()
|
||||
|
||||
assertEquals("8 个并发续期只能真的打一次刷新", 1, calls.get())
|
||||
results.forEach { assertEquals(AuthRecovery.Renewed(freshToken), it) }
|
||||
assertEquals(freshToken, store.load().token)
|
||||
assertEquals(rotatedRefreshToken, store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generation guard reuses a token another thread already refreshed`() {
|
||||
val calls = AtomicInteger()
|
||||
// 别人已经换好了:手里的令牌与 staleToken 不同、且没过期。
|
||||
val store = FakeSessionStorage(session(token = freshToken, expiresAt = nowSeconds + 86_400))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Ok("unused", nowSeconds, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
val recovery = refresher.renew(staleToken)
|
||||
|
||||
assertEquals(AuthRecovery.Renewed(freshToken), recovery)
|
||||
assertEquals("代际守卫生效时不该发刷新请求", 0, calls.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired token equal to the stale one is still refreshed`() {
|
||||
val calls = AtomicInteger()
|
||||
// 值相同但已经过期 → 守卫不能把它当成「别人刷好的令牌」放行。
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 60))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds + 86_400, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Renewed(freshToken), refresher.renew(staleToken))
|
||||
assertEquals(1, calls.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid_grant is terminal and is not retried for the same credential`() {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Rejected },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
|
||||
// 同一个凭据已经确定无效:并发的第二个 401 不该再打一次必然失败的请求。
|
||||
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
|
||||
assertEquals(1, calls.get())
|
||||
assertEquals("终态不能改动已存令牌", staleToken, store.load().token)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient failures stay distinguishable from terminal ones and remain retryable`() {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Failed },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Transient, refresher.renew(staleToken))
|
||||
assertEquals(AuthRecovery.Transient, refresher.renew(staleToken))
|
||||
// 网络 / 5xx 不是终态:必须留着下次再试,所以这里**不**记忆失败。
|
||||
assertEquals(2, calls.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing refresh token is terminal without any network call`() {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1, refresh = null))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
|
||||
assertEquals(0, calls.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rotation-disabled response passes a null refresh token straight through`() {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds + 86_400, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Renewed(freshToken), refresher.renew(staleToken))
|
||||
// 服务端不下发新凭据 → 传 null 给存储层,由它保留旧值(AUTH_REFRESH_ROTATE_DISABLED)。
|
||||
assertEquals(listOf<String?>(null), store.updateRefreshTokenArguments)
|
||||
assertEquals(staleRefreshToken, store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `proactive path refreshes only when the token is inside the skew`() {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds + 300))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds + 86_400, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
// 还有 5 分钟:余量(60s)之外,直接交出去,不打网络。
|
||||
assertEquals(staleToken, refresher.ensureToken())
|
||||
assertEquals(0, calls.get())
|
||||
|
||||
// 只剩 30 秒:进到余量里,先换令牌。
|
||||
store.setToken(staleToken, nowSeconds + 30)
|
||||
assertEquals(freshToken, refresher.ensureToken())
|
||||
assertEquals(1, calls.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `proactive path falls back to the stored token when refreshing fails`() {
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds + 10))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { RefreshCall.Failed },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
// 返回 null 会让整条请求链断掉;那一刻令牌可能还没真过期,交给服务端裁决更好。
|
||||
assertEquals(staleToken, refresher.ensureToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no stored token means nothing to hand out`() {
|
||||
val refresher = TokenRefresher(
|
||||
store = FakeSessionStorage(session(token = null, expiresAt = 0)),
|
||||
call = { RefreshCall.Ok(freshToken, nowSeconds, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertNull(refresher.ensureToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh rotation also clears the terminal memo`() {
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
|
||||
val calls = AtomicInteger()
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Rejected },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
|
||||
assertEquals(1, calls.get())
|
||||
|
||||
// 换一份凭据(重新登录)之后,同一条路径必须重新可以尝试。
|
||||
store.setRefreshToken("refresh-after-login")
|
||||
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
|
||||
assertEquals(2, calls.get())
|
||||
}
|
||||
|
||||
// —— 夹具 ——
|
||||
|
||||
private fun session(
|
||||
token: String?,
|
||||
expiresAt: Long,
|
||||
refresh: String? = staleRefreshToken,
|
||||
) = StoredSession(
|
||||
baseUrl = "http://127.0.0.1:10942",
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
role = "owner",
|
||||
username = "testuser",
|
||||
refreshToken = refresh,
|
||||
password = null,
|
||||
)
|
||||
|
||||
/** 内存假实现:写入必须立刻对 [load] 可见,否则代际守卫测不出来。 */
|
||||
private class FakeSessionStorage(initial: StoredSession) : SessionStorage {
|
||||
|
||||
@Volatile
|
||||
private var session: StoredSession = initial
|
||||
|
||||
/** `updateTokens` 收到的 refreshToken 实参,按调用顺序记录(含 null)。 */
|
||||
val updateRefreshTokenArguments = CopyOnWriteArrayList<String?>()
|
||||
|
||||
override fun load(): StoredSession = session
|
||||
|
||||
override fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
) {
|
||||
session = session.copy(
|
||||
baseUrl = baseUrl,
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
role = role,
|
||||
username = username,
|
||||
refreshToken = refreshToken,
|
||||
password = null,
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
|
||||
updateRefreshTokenArguments += refreshToken
|
||||
session = session.copy(
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
// 与生产实现同义:null 表示沿用原值。
|
||||
refreshToken = refreshToken ?: session.refreshToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun purgePassword() {
|
||||
session = session.copy(password = null)
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
session = session.copy(token = null, expiresAt = 0, role = null, refreshToken = null)
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
session = StoredSession("", null, 0, null, null, null, null)
|
||||
}
|
||||
|
||||
fun setToken(token: String, expiresAt: Long) {
|
||||
session = session.copy(token = token, expiresAt = expiresAt)
|
||||
}
|
||||
|
||||
fun setRefreshToken(refreshToken: String?) {
|
||||
session = session.copy(refreshToken = refreshToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* 端到端(真 OkHttp + 真 Retrofit + 真 HTTP 服务端)验证 B2 §4/§5/§6。
|
||||
*
|
||||
* 服务端用 JDK 自带的 `com.sun.net.httpserver`,**不引入任何新依赖**
|
||||
* (没有 MockWebServer)。这里是唯一能证明「拦截器真的会重试一次、且只重试一次」
|
||||
* 的地方——单测 [TokenRefresher] 只覆盖协调逻辑,覆盖不到它在 OkHttp 里的接线。
|
||||
*
|
||||
* 关键事实(服务端 `RefreshStore.Rotate` 原子且无宽限期):并发 401 里只要有第二个线程
|
||||
* 拿旧 refresh token 去刷,就会立刻 `invalid_grant`。所以「刷了几次」就是正确性判据。
|
||||
*/
|
||||
class UnauthorizedRetryEndToEndTest {
|
||||
|
||||
private val staleToken = "jwt-stale"
|
||||
private val freshToken = "jwt-fresh"
|
||||
private val refreshToken = "refresh-1"
|
||||
|
||||
private val backend = FakeBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two concurrent 401s cause exactly one refresh and both requests succeed`() = runBlocking {
|
||||
val store = storeWith(staleToken, expiresInSeconds = 3_600)
|
||||
val api = authedApi(store)
|
||||
backend.accepts = setOf(freshToken)
|
||||
// 扣住前两个请求不放,直到两个都已到达:这样「两个调用方都读到了旧令牌」是确定的,
|
||||
// 而不是靠调度碰运气。
|
||||
backend.gateConcurrentRequests = true
|
||||
|
||||
val responses = listOf(
|
||||
async(Dispatchers.IO) { api.listSessions() },
|
||||
async(Dispatchers.IO) { api.listSessions() },
|
||||
).awaitAll()
|
||||
|
||||
assertTrue("两个请求都应该靠续期后的令牌成功:$responses", responses.all { it.isSuccessful })
|
||||
assertEquals("两个并发调用方都必须先撞上 401", 2, backend.unauthorizedResponses.get())
|
||||
assertEquals("并发 401 只能真的打一次刷新", 1, backend.refreshBodies.size)
|
||||
assertEquals(refreshToken, backend.refreshedWith.single())
|
||||
assertEquals(freshToken, store.load().token)
|
||||
assertEquals("刷新后应换到新的 refresh token", "refresh-2", store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a near-expiry token is refreshed proactively so the first attempt already carries the new one`() =
|
||||
runBlocking {
|
||||
// 只剩 10 秒:进到 60s 余量里,tokenProvider 必须在发请求之前先换令牌。
|
||||
val store = storeWith(staleToken, expiresInSeconds = 10)
|
||||
val api = authedApi(store)
|
||||
backend.accepts = setOf(freshToken)
|
||||
|
||||
val response = api.listSessions()
|
||||
|
||||
assertTrue(response.isSuccessful)
|
||||
assertEquals(1, backend.refreshBodies.size)
|
||||
assertEquals("主动续期之后第一次请求就该带新令牌", 1, backend.sessionsRequests.size)
|
||||
assertEquals(0, backend.unauthorizedResponses.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an invalid_grant on refresh is terminal and ends the session`() = runBlocking {
|
||||
val store = storeWith(staleToken, expiresInSeconds = 3_600)
|
||||
val api = authedApi(store)
|
||||
backend.accepts = emptySet()
|
||||
backend.refreshStatus = 401
|
||||
backend.refreshBody = """{"error":{"code":"invalid_grant","message":"invalid refresh token"}}"""
|
||||
|
||||
val response = api.listSessions()
|
||||
|
||||
assertEquals(401, response.code())
|
||||
assertEquals(1, backend.refreshBodies.size)
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
assertNull("终态必须清掉本地令牌", store.load().token)
|
||||
assertNull(store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient refresh failure keeps the session alive`() = runBlocking {
|
||||
val store = storeWith(staleToken, expiresInSeconds = 3_600)
|
||||
val api = authedApi(store)
|
||||
backend.accepts = emptySet()
|
||||
backend.refreshStatus = 500
|
||||
backend.refreshBody = """{"error":{"code":"refresh_failed","message":"could not refresh token"}}"""
|
||||
|
||||
val response = api.listSessions()
|
||||
|
||||
assertEquals(401, response.code())
|
||||
// 网络 / 5xx 不是终态:会话必须留着,下次请求还能再试。
|
||||
assertEquals(SessionState.Unknown, manager.state.value)
|
||||
assertEquals(staleToken, store.load().token)
|
||||
assertEquals(refreshToken, store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a token that keeps being rejected is retried at most once and never storms`() = runBlocking {
|
||||
val store = storeWith(staleToken, expiresInSeconds = 3_600)
|
||||
val api = authedApi(store)
|
||||
// 服务端谁都不要:刷新成功,但新令牌照样 401。
|
||||
backend.accepts = emptySet()
|
||||
|
||||
val response = api.listSessions()
|
||||
|
||||
assertEquals(401, response.code())
|
||||
// 只刷一次;被拒的请求只重试一次。没有刷新风暴,也没有无限重试。
|
||||
assertEquals(1, backend.refreshBodies.size)
|
||||
assertEquals(2, backend.sessionsRequests.size)
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
}
|
||||
|
||||
// —— 夹具 ——
|
||||
|
||||
private lateinit var manager: AuthManager
|
||||
|
||||
/** 真实接线:拦截器用的 tokenProvider / policy 都来自真正的 [AuthManager]。 */
|
||||
private fun authedApi(store: SessionStorage): SessionsApi {
|
||||
manager = AuthManager(store) { ApiClientFactory.create(baseUrl = it) }
|
||||
return ApiClientFactory.create(
|
||||
baseUrl = backend.baseUrl,
|
||||
service = SessionsApi::class.java,
|
||||
tokenProvider = manager::currentToken,
|
||||
unauthorizedPolicy = manager.unauthorizedPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
private fun storeWith(token: String, expiresInSeconds: Long) = InMemorySessionStore(
|
||||
StoredSession(
|
||||
baseUrl = backend.baseUrl,
|
||||
token = token,
|
||||
expiresAt = System.currentTimeMillis() / 1000 + expiresInSeconds,
|
||||
role = "owner",
|
||||
username = "testuser",
|
||||
refreshToken = refreshToken,
|
||||
password = null,
|
||||
),
|
||||
)
|
||||
|
||||
private class InMemorySessionStore(initial: StoredSession) : SessionStorage {
|
||||
|
||||
@Volatile
|
||||
private var session: StoredSession = initial
|
||||
|
||||
override fun load(): StoredSession = session
|
||||
|
||||
override fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
) {
|
||||
session = session.copy(
|
||||
baseUrl = baseUrl,
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
role = role,
|
||||
username = username,
|
||||
refreshToken = refreshToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
|
||||
session = session.copy(
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
refreshToken = refreshToken ?: session.refreshToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun purgePassword() {
|
||||
session = session.copy(password = null)
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
session = session.copy(token = null, expiresAt = 0, role = null, refreshToken = null)
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
session = StoredSession("", null, 0, null, null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 极简假服务端:`/api/v1/sessions` 只认 [accepts] 里的访问令牌,
|
||||
* `/api/v1/auth/refresh` 按 [refreshStatus] / [refreshBody] 回。
|
||||
*/
|
||||
private inner class FakeBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(4)
|
||||
createContext("/api/v1/sessions", ::handleSessions)
|
||||
createContext("/api/v1/auth/refresh", ::handleRefresh)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
@Volatile
|
||||
var accepts: Set<String> = emptySet()
|
||||
|
||||
@Volatile
|
||||
var refreshStatus: Int = 200
|
||||
|
||||
@Volatile
|
||||
var refreshBody: String =
|
||||
"""{"data":{"token":"$freshToken","expiresAt":${System.currentTimeMillis() / 1000 + 86_400},""" +
|
||||
""""role":"owner","refreshToken":"refresh-2"}}"""
|
||||
|
||||
val sessionsRequests = CopyOnWriteArrayList<String>()
|
||||
val refreshBodies = CopyOnWriteArrayList<String>()
|
||||
|
||||
/** 打开后,未授权的请求会先等到「两个都到齐」再回 401,用来钉住真实的并发重叠。 */
|
||||
@Volatile
|
||||
var gateConcurrentRequests: Boolean = false
|
||||
|
||||
private val arrived = CountDownLatch(2)
|
||||
|
||||
/** 必须是原子的:两个请求会被 gate 扣住并发地写它。 */
|
||||
val unauthorizedResponses = AtomicInteger()
|
||||
|
||||
/** 送到 `/auth/refresh` 的 refreshToken 值(去引号后的原样)。 */
|
||||
val refreshedWith: List<String>
|
||||
get() = refreshBodies.map { it.substringAfter("\"refreshToken\":\"").substringBefore('"') }
|
||||
|
||||
private fun handleSessions(exchange: HttpExchange) {
|
||||
val authorization = exchange.requestHeaders.getFirst("Authorization").orEmpty()
|
||||
val token = authorization.removePrefix("Bearer ").takeIf { authorization.startsWith("Bearer ") }
|
||||
sessionsRequests += authorization
|
||||
if (token != null && token in accepts) {
|
||||
respond(exchange, 200, """{"data":[]}""")
|
||||
} else {
|
||||
if (gateConcurrentRequests) {
|
||||
arrived.countDown()
|
||||
arrived.await(GATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
}
|
||||
unauthorizedResponses.incrementAndGet()
|
||||
respond(exchange, 401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRefresh(exchange: HttpExchange) {
|
||||
refreshBodies += exchange.requestBody.readBytes().decodeToString()
|
||||
respond(exchange, refreshStatus, refreshBody)
|
||||
}
|
||||
|
||||
private fun respond(exchange: HttpExchange, status: Int, body: String) {
|
||||
val bytes = body.toByteArray()
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(status, bytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val GATE_TIMEOUT_SECONDS = 5L
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.EnumItemDto
|
||||
import top.yeij.cyrene.data.api.dto.EnumTableDto
|
||||
|
||||
/**
|
||||
* 快照的指纹闸门:`baseUrl + username` 变了,上一份权威表就作废——
|
||||
* 绝不把别的服务器/账号的标签拿来用(宁可原样显示 id)。
|
||||
*/
|
||||
class EnumPrefsTest {
|
||||
|
||||
private val table = EnumTableDto(platforms = listOf(EnumItemDto("web", "网页")))
|
||||
|
||||
private val fingerprint = "http://host:10942/|testuser"
|
||||
|
||||
@Test
|
||||
fun `a matching fingerprint yields the snapshot`() {
|
||||
assertEquals(table, EnumPrefs(table, fingerprint).tableFor(fingerprint))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `another account or another server invalidates the snapshot`() {
|
||||
val prefs = EnumPrefs(table, fingerprint)
|
||||
|
||||
assertNull(prefs.tableFor("http://host:10942/|someone-else"))
|
||||
assertNull(prefs.tableFor("http://other-host:10942/|testuser"))
|
||||
assertNull(prefs.tableFor("|"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no snapshot yet stays null even for the matching fingerprint`() {
|
||||
assertNull(EnumPrefs(table = null, fingerprint = fingerprint).tableFor(fingerprint))
|
||||
assertNull(EnumPrefs().tableFor(""))
|
||||
}
|
||||
}
|
||||