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

No files matched your search

+28 -5
View File
@@ -1,12 +1,35 @@
*.iml
# ---> Android
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
.idea/
.DS_Store
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
# 本地设计/协作文档,不进仓库
devdocs/
+73
View File
@@ -0,0 +1,73 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2026 AskaEth
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+3
View File
@@ -0,0 +1,3 @@
# Cyrene-For-Android
昔涟在安卓设备上的载体。
-78
View File
@@ -1,78 +0,0 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "top.yeij.cyrene"
compileSdk = 37
buildToolsVersion = "37.0.0"
defaultConfig {
applicationId = "top.yeij.cyrene"
minSdk = 33
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.foundation)
implementation(libs.miuix.ui)
implementation(libs.miuix.icons)
implementation(libs.miuix.preference)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore.preferences)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit)
}
-10
View File
@@ -1,10 +0,0 @@
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class **$$serializer {
*** descriptor;
}
-keepclasseswithmembers class ** {
kotlinx.serialization.KSerializer serializer(...);
}
-26
View File
@@ -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,75 +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.SessionPreferences
import top.yeij.cyrene.data.api.AdminApi
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.repository.AdminRepository
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.SessionRepository
import top.yeij.cyrene.data.ws.ChatSocket
/** 手写 DI 容器(D:不引 Hilt)。 */
class AppContainer(context: Context) {
val sessionStore: SessionStore = SessionStore(context.applicationContext)
val sessionPreferences: SessionPreferences = SessionPreferences(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 = { sessionStore.load().token },
onUnauthorized = { authManager.onSessionExpired() },
)
cachedBaseUrl = baseUrl
cachedSessionsApi = created
return created
}
val sessionRepository: SessionRepository get() = SessionRepository(sessionsApi)
val messageRepository: MessageRepository get() = MessageRepository(sessionsApi)
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 = { sessionStore.load().token },
onUnauthorized = { authManager.onSessionExpired() },
)
cachedAdminBaseUrl = baseUrl
cachedAdminApi = created
return created
}
val adminRepository: AdminRepository get() = AdminRepository(adminApi)
fun createChatSocket(): ChatSocket = ChatSocket(
baseUrlProvider = { authManager.currentBaseUrl },
tokenProvider = { sessionStore.load().token },
onAuthFailed = { authManager.onSessionExpired() },
)
}
@@ -1,28 +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 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()
LaunchedEffect(Unit) {
container.authManager.bootstrap()
}
when (state) {
SessionState.Unknown -> LoadingScreen()
SessionState.LoggedOut -> LoginScreen(container)
is SessionState.LoggedIn -> ChatHomeScreen(
container = container,
onLogout = { 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,61 +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 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.yukonga.miuix.kmp.theme.ColorSchemeMode
/**
* 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] 下发给渲染层,
* 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。
*/
@Composable
fun CyreneRoot(container: AppContainer) {
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
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) {
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,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,121 +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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
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
}
class AuthManager(private val store: SessionStore) {
private val _state = MutableStateFlow<SessionState>(SessionState.Unknown)
val state: StateFlow<SessionState> = _state.asStateFlow()
private var api: CyreneApi? = null
var currentBaseUrl: String = ""
private set
/** 启动时:有效 token 直接进入;过期则用已存凭据静默重登(D12);否则回登录页。 */
suspend fun bootstrap() = withContext(Dispatchers.IO) {
val stored = store.load()
currentBaseUrl = stored.baseUrl
when {
!stored.token.isNullOrBlank() && stored.expiresAt * 1000 > System.currentTimeMillis() -> {
_state.value = SessionState.LoggedIn(stored.baseUrl, stored.role.orEmpty())
}
!stored.username.isNullOrBlank() && !stored.password.isNullOrBlank() -> {
val error = doLogin(stored.baseUrl, stored.username, stored.password)
if (error != null) _state.value = SessionState.LoggedOut
}
else -> _state.value = SessionState.LoggedOut
}
}
suspend fun login(rawBaseUrl: String, username: String, password: String): CyreneError? =
withContext(Dispatchers.IO) {
doLogin(ApiClientFactory.normalizeBaseUrl(rawBaseUrl), username, password)
}
fun logout() {
store.clearAll()
api = null
_state.value = SessionState.LoggedOut
}
/** 会话过期(401):清 token、回登录页;凭据保留以便下次静默重登。 */
fun onSessionExpired() {
store.clearSession()
api = null
_state.value = SessionState.LoggedOut
}
private suspend fun doLogin(url: String, username: String, password: String): CyreneError? {
val client = ApiClientFactory.create(baseUrl = url)
return try {
val response = client.login(LoginRequest(username, password))
val data = response.body()?.data
when {
response.isSuccessful && data != null -> {
store.saveLogin(url, data.token, data.expiresAt, data.role, username, password)
api = client
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() == 401) {
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)
}
}
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)
}
}
@@ -1,78 +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?,
val password: String?,
)
/** JWT 与登录凭据的加密存储(红线:不落明文、不打日志)。 */
class SessionStore(context: Context) {
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,
)
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),
password = prefs.getString(KEY_PASSWORD, null),
)
fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
password: String,
) {
prefs.edit()
.putString(KEY_BASE_URL, baseUrl)
.putString(KEY_TOKEN, token)
.putLong(KEY_EXPIRES_AT, expiresAt)
.putString(KEY_ROLE, role)
.putString(KEY_USERNAME, username)
.putString(KEY_PASSWORD, password)
.apply()
}
/** 会话失效(401/过期):清 token,但保留凭据以便静默重登(D12)。 */
fun clearSession() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_EXPIRES_AT)
.remove(KEY_ROLE)
.apply()
}
/** 主动登出:凭据一并清掉,避免下次启动被静默登回去。 */
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_PASSWORD = "password"
}
}
@@ -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,137 +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 kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
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 order `sessionId -> 显示序号`(后端 title 恒空,序号本地生成)
* @param activeSessionId 上次停留的会话
*/
data class SessionPrefs(
val snapshot: List<SessionSummaryDto> = emptyList(),
val snapshotAt: Long = 0L,
val fingerprint: String = "",
val order: Map<String, Int> = emptyMap(),
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
)
class SessionPreferences(context: Context) {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
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(),
order = prefs[KEY_ORDER].decodeOrder(),
activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID,
)
}
/**
* 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵),
* 同时为新出现的次级会话分配显示序号、清理已消失会话的序号。
*/
suspend fun saveSnapshot(sessions: List<SessionSummaryDto>, fingerprint: String) {
store.edit { prefs ->
val sameAccount = prefs[KEY_FINGERPRINT].orEmpty() == fingerprint
val order = if (sameAccount) prefs[KEY_ORDER].decodeOrder().toMutableMap() else mutableMapOf()
var next = order.values.maxOrNull() ?: 0
sessions.filterNot { it.isMain }.forEach { dto ->
if (order[dto.id] == null) order[dto.id] = ++next
}
order.keys.retainAll(sessions.map { it.id }.toSet())
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions)
prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis()
prefs[KEY_FINGERPRINT] = fingerprint
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
}
}
suspend fun setActive(sessionId: String) {
store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId }
}
/** 分配显示序号(当前最大值 + 1);已分配过则原样返回。 */
suspend fun ensureOrder(sessionId: String): Int {
var assigned = 0
store.edit { prefs ->
val order = prefs[KEY_ORDER].decodeOrder().toMutableMap()
val existing = order[sessionId]
if (existing != null) {
assigned = existing
} else {
assigned = (order.values.maxOrNull() ?: 0) + 1
order[sessionId] = assigned
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
}
}
return assigned
}
/** 删除会话:清序号、必要时把 active 退回主会话、同步从快照里剔除。 */
suspend fun removeSession(sessionId: String) {
store.edit { prefs ->
val order = prefs[KEY_ORDER].decodeOrder().toMutableMap()
order.remove(sessionId)
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
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)
}
}
/** 登出 / 切换服务器时清空。 */
suspend fun clear() {
store.edit { it.clear() }
}
private fun String?.decodeSessions(): List<SessionSummaryDto> = this
?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() }
.orEmpty()
private fun String?.decodeOrder(): Map<String, Int> = this
?.let { runCatching { json.decodeFromString(OrderSerializer, 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_ORDER = stringPreferencesKey("sessions_order")
val KEY_ACTIVE = stringPreferencesKey("active_session_id")
val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer())
val OrderSerializer = MapSerializer(String.serializer(), Int.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,90 +0,0 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.BuildConfig
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
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 },
onUnauthorized: () -> Unit = {},
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, onUnauthorized)
/** 通用入口:按需构建任意 Retrofit service。 */
fun <T : Any> create(
baseUrl: String,
service: Class<T>,
tokenProvider: () -> String? = { null },
onUnauthorized: () -> Unit = {},
): T {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.addInterceptor(HeaderInterceptor(tokenProvider, onUnauthorized))
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(
HttpLoggingInterceptor().apply {
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
}
private class HeaderInterceptor(
private val tokenProvider: () -> String?,
private val onUnauthorized: () -> Unit,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider()
val request = chain.request().newBuilder()
.header("X-API-Version", "1")
.apply { if (token != null) header("Authorization", "Bearer $token") }
.build()
val response = chain.proceed(request)
// 只有「带了 token 仍 401」才算会话过期;登录请求不带 token,不受影响。
if (response.code == 401 && token != null) {
onUnauthorized()
}
return response
}
}
}
@@ -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,13 +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 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>>
}
@@ -1,44 +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 retrofit2.Response
import retrofit2.http.DELETE
import retrofit2.http.GET
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>>
/**
* 消息热分页,服务端按 `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,16 +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,
val expiresAt: Long,
val role: String,
)
@@ -1,24 +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,
val createdAt: Long,
)
@@ -1,36 +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"`,且由服务端排在首位。
*/
@Serializable
data class SessionSummaryDto(
val id: String,
val type: String,
val title: String? = null,
val createdAt: Long,
) {
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,
)
/** `DELETE /api/v1/sessions/{id}` → `200 {"data":{"deleted":"<id>"}}`。 */
@Serializable
data class DeleteSessionResponse(
val deleted: String,
)
@@ -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,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,40 +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)
}
@@ -1,35 +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 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`,原样返回服务端顺序(主会话恒首位)。 */
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 }
}
/** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
apiCall { api.deleteSession(sessionId) }.map { it.deleted }
}
}
@@ -1,254 +0,0 @@
package top.yeij.cyrene.data.ws
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()
* ```
*
* ## 会话归属约束
* `message.*` / `thinking.*` / `tool_call.*` / `stream_chunk` 等流式帧**不带 `sessionId`**,
* 本类不会替它们编造会话。上层必须把它们归属到「当前唯一的 active 流式会话」——
* 也就是**同一时刻只允许一个会话在流式**(切会话时需先结束或 `interrupt`)。
*
* ## 心跳与重连
* - OkHttp `pingInterval` 30s(服务端 ping 周期 54s / pong 等待 60s,客户端 30s 足够)。
* - 服务端**没有 resume / 补发**:重连成功后由上层用新连接的 `history` 帧 + REST 补差去重。
* - 退避 1s/2s/4s/8s/16s/30s(上限 30s,含抖动)。
* - 握手返回 **HTTP 401** → [WsState.Offline],触发 [onAuthFailed],**不再重连**(需重新登录)。
*
* ## 坏帧
* 每帧解析都走 [WsEvent.parse],非 JSON / 未知 `type` / 结构不匹配一律丢弃,不会中断连接。
*
* @param baseUrlProvider 服务器根地址(如 `http://host:8080`),每次重连时重新读取。
* @param tokenProvider 当前 JWT,每次重连时重新读取(支持静默重登后复用同一实例)。
* @param onAuthFailed 握手 401 回调;在协程内调用,注意切主线程。
* @param client 可注入自定义 OkHttpClient(会 `newBuilder` 派生,保留其拦截器);缺省新建。
*/
class ChatSocket(
private val baseUrlProvider: () -> String?,
private val tokenProvider: () -> String?,
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
private var scope: CoroutineScope? = null
/** 开始连接(幂等:已在连接 / 重连中时忽略)。 */
fun connect() {
if (wantConnection) return
wantConnection = true
attempt = 0
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)。
* @return 未连接或写失败返回 `false`(本层不做离线队列,由上层标红重试)。
*/
fun sendChat(sessionId: String?, text: String, deviceId: String?): Boolean =
send(WsOutbound.Chat(text = text, sessionId = sessionId, deviceId = deviceId))
/** 发送 `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 -> {
wantConnection = false
_state.value = WsState.Offline
onAuthFailed()
}
SessionEnd.Closed -> {
if (!wantConnection) break
_state.value = WsState.Reconnecting
delay(backoffDelayMs(attempt))
attempt += 1
}
}
}
} catch (_: CancellationException) {
// close() 主动取消,正常退出。
}
}
/** 建立一次连接并挂起,直到它关闭 / 失败 / 被取消。 */
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,459 +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。
*
* ## 会话归属约束(重要)
* `message.start/delta/end`、`thinking.*`、`tool_call.*`、`tool_execution.update`、
* `stream_chunk` **不带 `sessionId`**。本层不会凭空补 `sessionId`,因此上层必须把这些事件
* 归属到「当前唯一的 active 流式会话」——即**同一时刻只允许一个会话在流式**。
* 带 `sessionId` 的是 `history` / `stream_end` / `message_persisted` / `channel_persisted`
* 以及编排状态帧(`session_busy` / `pending_*` / `session_idle` / `session_interrupted`)。
*
* ## 未知事件与坏帧
* [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
/** 能确定会话的事件才有;流式正文/思维链/工具帧恒为 `null`。 */
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,
) : 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,
) : 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,
) : WsEvent() {
override val type: String get() = "message.end"
}
/** 思维链段开始。 */
data class ThinkingStart(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = 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,
) : WsEvent() {
override val type: String get() = "thinking.delta"
}
/** 思维链段结束。 */
data class ThinkingEnd(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = 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,
) : 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,
) : 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,
) : WsEvent() {
override val type: String get() = "tool_call.end"
}
/** 工具执行进度 / 局部输出。 */
data class ToolExecutionUpdate(
val id: String,
val index: Int,
val delta: String,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "tool_execution.update"
}
/** 向后兼容整段文本(仅非流式回退路径;流式路径不 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"
}
/** 主会话(`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)
"message.delta" -> decode<DeltaIndexData>(data).let { MessageDelta(it.delta, it.index, turnId, seq) }
"message.end" -> MessageEnd(decode<IndexData>(data).index, turnId, seq)
"thinking.start" -> ThinkingStart(decode<IndexData>(data).index, turnId, seq)
"thinking.delta" -> decode<DeltaIndexData>(data).let { ThinkingDelta(it.delta, it.index, turnId, seq) }
"thinking.end" -> ThinkingEnd(decode<IndexData>(data).index, turnId, seq)
"tool_call.start" -> decode<ToolCallIdData>(data).let { ToolCallStart(it.id, it.index, turnId, seq) }
"tool_call.delta" -> decode<ToolCallData>(data).let { ToolCallDelta(it.id, it.index, it.delta, turnId, seq) }
"tool_call.end" -> decode<ToolCallIdData>(data).let { ToolCallEnd(it.id, it.index, turnId, seq) }
"tool_execution.update" -> decode<ToolCallData>(data)
.let { ToolExecutionUpdate(it.id, it.index, it.delta, turnId, seq) }
"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) }
"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()))
}
}
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 = "")
@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)
@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,118 +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?}`;text+image 至少一项。 */
data class Chat(
val text: String,
val sessionId: String? = null,
val deviceId: String? = null,
val image: 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) }
}
}
/** `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,121 +0,0 @@
package top.yeij.cyrene.domain.admin
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
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 personaModeLabels = mapOf(
"chat" to "对话",
"code" to "代码",
"home" to "家居",
"work" to "工作",
"docs" to "文档",
"voice" to "语音",
"vision" to "视觉",
"agents" to "智能体",
"settings" to "设置",
)
private val memoryScopeLabels = mapOf(
"user" to "用户",
"persona" to "人格",
"project" to "项目",
)
private val moodLabels = mapOf(
"calm" to "平静",
"happy" to "开心",
"excited" to "兴奋",
"sad" to "低落",
"angry" to "生气",
)
/**
* 未知取值回落约定(api-client §1 红线):不在表中的取值**原样返回**——
* 不丢弃、不报错、不替换占位符。服务端新增取值属非破坏性变更。
*/
fun traceKindLabel(raw: String): String = traceKindLabels[raw] ?: raw
fun traceStatusLabel(raw: String): String = traceStatusLabels[raw] ?: raw
fun personaModeLabel(raw: String): String = personaModeLabels[raw] ?: raw
fun memoryScopeLabel(raw: String): String = memoryScopeLabels[raw] ?: raw
fun moodLabel(raw: String): String = moodLabels[raw] ?: raw
/** 三态展示:null(未配置)与 true/false 是**三种不同**状态,不能合并。 */
fun triStateLabel(value: Boolean?): String = when (value) {
null -> "未配置"
true -> "显示"
false -> "不显示"
}
/** 空白字段的展示兜底:只处理「没有值」,不参与未知取值的回落。 */
fun orDash(raw: String?): String = raw?.takeIf { it.isNotBlank() } ?: "-"
@@ -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,67 +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.data.api.dto.AdminBackthinkItemDto
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
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] 单独解析。
*/
@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) {
AdminTextCard(
title = orDash(record.trigger),
body = backthinkBody(record),
trailing = if (record.at > 0) formatEpochMillis(record.at) else "-",
)
}
private fun backthinkBody(record: AdminBackthinkItemDto): String = buildList {
record.conclusion?.takeIf { it.isNotBlank() }?.let { add(it) }
add("通知 ${if (record.shouldNotify) "需推送" else "不推送"} · ${if (record.pushed) "已送达" else "未送达"}")
}.joinToString("\n")
@@ -1,231 +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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.admin.AdminFailure
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)
}
}
}
}
/** 管理与详情页里的普通文本块(长文本用:[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)) {
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,
)
}
}
if (body.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = body,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
@@ -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,112 +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.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) {
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).ifBlank { "-" })
PersonaDetail("记忆域", persona.memoryNamespace.ifBlank { "-" })
// mood 为空表示「该角色暂无被跟踪的情绪」,不伪造默认值。
PersonaDetail("情绪", persona.mood?.takeIf { it.isNotBlank() }?.let(::moodLabel) ?: "暂无")
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,86 +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,
onOpenChange = { drawerOpen = it },
content = {
ChatScreen(
container = container,
sessionId = state.activeSessionId,
onOpenDrawer = { drawerOpen = true },
onLogout = onLogout,
)
},
drawerContent = {
SessionDrawerContent(
state = state,
onSelect = { sessionId ->
sessionsViewModel.select(sessionId)
drawerOpen = false
},
onCreate = sessionsViewModel::createSession,
onDelete = { sessionsViewModel.deleteSession(it.id) },
onOpenIot = { /* P2:IoT 页面 */ },
onOpenAdmin = {
drawerOpen = false
showAdmin = true
},
onOpenSettings = {
drawerOpen = false
showSettings = true
},
)
},
)
}
}
@@ -1,255 +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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.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.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 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.Sidebar
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()
// 是否跟随底部(微信/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,
busyText = busyLabel(state),
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
)
},
) { 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,
busyText: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.imePadding()
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
) {
Row(verticalAlignment = Alignment.Bottom) {
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,
enabled = value.isNotBlank(),
) {
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,
)
}
}
}
@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 -> ""
}
/** 输入栏下方的忙碌提示。 */
private fun busyLabel(state: ChatUiState): String = when {
state.busyMode != null && state.queueDepth > 0 -> "正在思考… 已排队 ${state.queueDepth} 条"
state.busyMode != null -> "正在思考…"
else -> ""
}
@@ -1,383 +0,0 @@
package top.yeij.cyrene.ui.chat
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.update
import kotlinx.coroutines.launch
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.repository.MessageRepository
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.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.StreamingContentParser
const val MAIN_SESSION_ID = "web"
/** 本端标识(design/16 §4.5 允许的可读前缀形态)。 */
private const val DEVICE_ID = "voiceassist:cyrene:v1"
/** 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。 */
data class PendingMessage(val localId: String, val text: String)
/** 一次工具调用的中间态(参数增量累积 + 执行输出)。 */
data class ToolCallState(
val id: String,
val index: Int,
val arguments: String = "",
val output: String = "",
val done: Boolean = false,
)
/** 正在流式的助手回复。 */
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 = "",
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,
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`(含乐观消息归一)
* 4. WS 流式 `thinking.* / tool_call.* / message.* / stream_end`(临时气泡,落库后由 3 取代)
*/
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
private var pendingSeq = 0
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) }
/** 发送:先乐观上屏,WS 上行失败则回滚并把文本还给输入框。 */
fun send() {
val text = _state.value.draft.trim()
if (text.isEmpty()) return
val localId = "local-${pendingSeq++}"
_state.update {
it.copy(
pending = it.pending + PendingMessage(localId, text),
draft = "",
error = null,
)
}
val sent = socket?.sendChat(sessionId, text, DEVICE_ID) ?: false
if (!sent) {
_state.update {
it.copy(
pending = it.pending.filterNot { p -> p.localId == localId },
draft = text,
error = "发送失败,请重试",
)
}
}
}
private fun onEvent(event: WsEvent) {
when (event) {
is WsEvent.History -> {
val incoming = event.messages
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 {
it.copy(messages = mergeMessages(it.messages, 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.ToolExecutionUpdate -> updateStreaming { streaming ->
streaming.copy(
tools = streaming.tools.map {
if (it.id == event.id) it.copy(output = it.output + event.delta) else it
},
)
}
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) }
}
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()))
}
}
/**
* 落库消息到达:
* - user:按「同文本」把对应的乐观气泡去掉(协议没有 clientMsgId,只能这样匹配)
* - 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 ->
val pending = if (msg.role == "user") {
val index = state.pending.indexOfFirst { it.text == msg.content }
if (index >= 0) state.pending.filterIndexed { i, _ -> i != index } else state.pending
} else {
state.pending
}
state.copy(
messages = mergeMessages(state.messages, listOf(persisted)),
pending = 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,189 +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.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"
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)) {
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)
}
}
}
/** 乐观发送中的用户消息(还没落库)。 */
@Composable
fun PendingBubble(item: PendingMessage) {
MessageGroup(isUser = true, footer = "发送中…") {
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,42 +0,0 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
private val platformLabels = mapOf(
"web" to "Web",
"kook" to "KOOK",
"weixin-ilink" to "微信",
"qq-obv11" to "QQ",
"telegram" to "Telegram",
"webhook" to "Webhook",
)
/**
* 来源角标(S1.3):每条消息默认显示来源。
* - 主人发的 → 「来自 X」(从哪一端发出)
* - 昔涟发的 → 「发送至 X」(回到哪一端)
* 优先用 `deviceId` 区分具体端,再回落到 `platform`;两者都没有则不显示。
*/
fun messageSourceLabel(message: HistoryMessageDto): String? {
val name = sourceName(message) ?: return null
val verb = if (message.role == "assistant") "发送至" else "来自"
return "$verb $name"
}
private fun sourceName(message: HistoryMessageDto): String? {
val deviceId = message.deviceId
if (!deviceId.isNullOrBlank()) {
if (deviceId.startsWith("voiceassist")) return "安卓"
if (deviceId.startsWith("web:")) {
val browser = deviceId.removePrefix("web:").substringBefore(':')
return "Web($browser)"
}
// IM 端 deviceId 形如 <平台名>:<配置id>:<uid>,只取平台名做友好显示
platformLabels[deviceId.substringBefore(':')]?.let { return it }
return deviceId
}
val platform = message.platform
if (!platform.isNullOrBlank()) return platformLabels[platform] ?: platform
return null
}
@@ -1,138 +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.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)
}
}
}
/** 工具调用卡片:参数与执行输出分开显示,默认折叠。 */
@Composable
fun ToolCard(
tool: ToolCallState,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
FoldCard(modifier = modifier, onClick = { expanded = !expanded }) {
FoldCardHeader(
title = if (tool.done) "工具调用" else "调用工具…",
expanded = expanded,
)
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) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = title,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
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,197 +0,0 @@
package top.yeij.cyrene.ui.sessions
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.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.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Add
import top.yukonga.miuix.kmp.icon.extended.Delete
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 侧边栏内容:会话列表 + IoT/设置入口。列表项为 MD3 小圆角卡片。 */
@Composable
fun ColumnScope.SessionDrawerContent(
state: SessionsUiState,
onSelect: (String) -> Unit,
onCreate: () -> Unit,
onDelete: (SessionItem) -> Unit,
onOpenIot: () -> Unit,
onOpenAdmin: () -> Unit,
onOpenSettings: () -> Unit,
) {
var pendingDelete 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) },
onDelete = { pendingDelete = session },
)
}
}
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 },
)
}
}
@Composable
private fun SessionRow(
session: SessionItem,
active: Boolean,
onClick: () -> Unit,
onDelete: () -> Unit,
) {
PreferenceCard {
BasicComponent(
title = session.title,
onClick = onClick,
endActions = {
if (active) {
Text(
text = "✓",
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
if (!session.isMain) {
IconButton(onClick = onDelete) {
Icon(
imageVector = MiuixIcons.Delete,
contentDescription = "删除会话",
tint = MiuixTheme.colorScheme.error,
)
}
}
},
)
}
}
@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)
}
}
}
}
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,130 +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.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
data class SessionItem(
val id: String,
val title: String,
val isMain: Boolean,
)
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):
* 启动先用本地快照渲染,再拉服务端;成功则整体替换并回写快照,失败则保留快照并提示。
*/
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 cached = prefs.data.first()
_state.update {
it.copy(
sessions = cached.snapshot.map { dto -> dto.toItem(cached.order) },
activeSessionId = cached.activeSessionId,
fromCache = cached.snapshot.isNotEmpty(),
syncedAt = cached.snapshotAt,
)
}
refresh()
}
}
fun refresh() {
viewModelScope.launch {
_state.update { it.copy(loading = true) }
container.sessionRepository.listSessions()
.onSuccess { list ->
prefs.saveSnapshot(list, fingerprint())
val cached = prefs.data.first()
_state.update {
it.copy(
sessions = list.map { dto -> dto.toItem(cached.order) },
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()) } }
}
}
fun dismissError() = _state.update { it.copy(error = null) }
private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
}
private fun SessionSummaryDto.toItem(order: Map<String, Int>): SessionItem = SessionItem(
id = id,
title = if (isMain) "与昔涟" else "会话 ${order[id] ?: "?"}",
isMain = isMain,
)
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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

-6
View File
@@ -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>
-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#F8F8F8</color>
</resources>
-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Cyrene</string>
</resources>
-6
View File
@@ -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,155 +0,0 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiEnvelope
/**
* 用真实服务端形状的 JSON 校验 DTO 契约(api-client §1/§2):
* `{data}` 信封、开放集合取值、`thinking` 三态、RFC3339 与 Unix 毫秒两种时间。
*/
class AdminDtoDecodingTest {
private val json = ApiClientFactory.json
private val traceSpans = ListSerializer(AdminTraceSpanDto.serializer())
private val personaList = ListSerializer(PersonaSummaryDto.serializer())
private val memoryList = ListSerializer(MemorySummaryDto.serializer())
private val backthinkList = ListSerializer(AdminBackthinkItemDto.serializer())
@Test
fun `metrics envelope decodes integer-shaped doubles and count maps`() {
val body = """
{"data":{"totalRequests":12,"qps":3,"avgLatencyMs":10,"maxLatencyMs":40,
"perMethod":{"GET":12},"perPath":{"/api/v1/x":12},"statusCounts":{"200":11,"500":1},
"llmCalls":2,"subagentEvents":0}}
""".trimIndent()
val data = json
.decodeFromString(ApiEnvelope.serializer(AdminMetricsDto.serializer()), body)
.data!!
assertEquals(12L, data.totalRequests)
assertEquals(3.0, data.qps, 0.0)
assertEquals(10.0, data.avgLatencyMs, 0.0)
assertEquals(mapOf("GET" to 12L), data.perMethod)
assertEquals(mapOf("200" to 11L, "500" to 1L), data.statusCounts)
}
@Test
fun `metrics tolerates missing optional fields`() {
val data = json
.decodeFromString(ApiEnvelope.serializer(AdminMetricsDto.serializer()), """{"data":{}}""")
.data!!
assertEquals(0L, data.totalRequests)
assertTrue(data.perMethod.isEmpty())
}
@Test
fun `trace span keeps unknown kind and arbitrary props`() {
val body = """
{"data":[{"id":"s1","parentId":"s0","name":"loop","kind":"quantum_hop","status":"ok",
"startedAt":"2026-03-04T04:34:56Z","endedAt":"2026-03-04T04:34:57Z","durationNs":1000000000,
"props":{"model":"gpt","n":3,"flag":true},
"events":[{"time":"2026-03-04T04:34:56.123456789Z","type":"retry","props":{"attempt":2}}]}]}
""".trimIndent()
val span = json
.decodeFromString(ApiEnvelope.serializer(traceSpans), body)
.data!!
.single()
assertEquals("quantum_hop", span.kind)
assertEquals("s0", span.parentId)
assertEquals(1_000_000_000L, span.durationNs)
val props = span.props!!
assertTrue(props.isNotEmpty())
assertEquals("gpt", (props["model"] as JsonPrimitive).content)
assertEquals(3, (props["n"] as JsonPrimitive).int)
assertTrue((props["flag"] as JsonPrimitive).boolean)
val events = span.events!!
assertEquals(1, events.size)
assertEquals("retry", events.single().type)
assertEquals(2, (events.single().props!!["attempt"] as JsonPrimitive).int)
}
@Test
fun `trace span tolerates missing parent and events`() {
val span = json
.decodeFromString(
ApiEnvelope.serializer(traceSpans),
"""{"data":[{"id":"s1","name":"loop","kind":"llm_call","status":"error","startedAt":"2026-03-04T04:34:56Z","durationNs":1}]}""",
)
.data!!
.single()
assertNull(span.parentId)
assertNull(span.endedAt)
assertNull(span.props)
assertNull(span.events)
}
@Test
fun `persona thinking is absent when unconfigured and keeps tri-state when present`() {
val body = """
{"data":[
{"id":"cyrene","name":"昔涟","character":"memokeeper","isDefault":true,"mode":"chat",
"memoryNamespace":"cyrene","mood":"",
"thinking":{"budgetTokens":0,"effort":"","showToUser":null,"mode":""}},
{"id":"other","name":"?","character":"","isDefault":false,"mode":"telepathy",
"memoryNamespace":"","mood":"euphoric"}]}
""".trimIndent()
val personas = json
.decodeFromString(ApiEnvelope.serializer(personaList), body)
.data!!
assertEquals(2, personas.size)
val configured = personas[0].thinking!!
assertNull("null 不能拍平成 false", configured.showToUser)
assertEquals(0, configured.budgetTokens)
assertNull("未配置的人格 thinking 键完全不出现", personas[1].thinking)
assertEquals("euphoric", personas[1].mood)
// 未知 mode 原样保留,不丢弃、不替换占位符。
assertEquals("telepathy", personas[1].mode)
}
@Test
fun `memory scope and backthink millis decode as declared`() {
val memories = json
.decodeFromString(ApiEnvelope.serializer(memoryList), """{"data":[{"id":"m1","scope":"shared","text":"hi"}]}""")
.data!!
assertEquals("shared", memories.single().scope)
val backthink = json
.decodeFromString(
ApiEnvelope.serializer(backthinkList),
"""{"data":[{"id":"7","trigger":"cron","shouldNotify":true,"pushed":false,"at":1772598896000}]}""",
)
.data!!
.single()
assertNull(backthink.conclusion)
assertTrue(backthink.shouldNotify)
assertFalse(backthink.pushed)
assertEquals(1_772_598_896_000L, backthink.at)
}
@Test
fun `unknown scope is preserved raw instead of being dropped`() {
val memories = json
.decodeFromString(ApiEnvelope.serializer(memoryList), """{"data":[{"id":"m1","scope":"galaxy","text":"hi"}]}""")
.data!!
assertEquals("galaxy", memories.single().scope)
}
}
@@ -1,88 +0,0 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import org.junit.Assert.assertEquals
import org.junit.Test
class MessageMergeTest {
private fun msg(id: String, createdAt: Long, content: String = id) = HistoryMessageDto(
id = id,
sessionId = "web",
role = "user",
content = content,
createdAt = createdAt,
)
private fun idsOf(messages: List<HistoryMessageDto>) = messages.map { it.id }
@Test
fun `overlapping page dedupes by id`() {
val existing = listOf(msg("m2", 200), msg("m1", 100))
val incoming = listOf(msg("m3", 300), msg("m2", 200))
val merged = mergeMessages(existing, incoming)
assertEquals(listOf("m1", "m2", "m3"), idsOf(merged))
}
@Test
fun `out of order incoming page is sorted ascending`() {
val existing = listOf(msg("m1", 100), msg("m2", 200))
val incoming = listOf(msg("m4", 400), msg("m3", 300))
val merged = mergeMessages(existing, incoming)
assertEquals(listOf("m1", "m2", "m3", "m4"), idsOf(merged))
}
@Test
fun `same millisecond falls back to id ordering`() {
val existing = listOf(msg("b", 100), msg("a", 100))
val incoming = listOf(msg("c", 100))
val merged = mergeMessages(existing, incoming)
assertEquals(listOf("a", "b", "c"), idsOf(merged))
}
@Test
fun `empty incoming page keeps existing`() {
val existing = listOf(msg("m1", 100), msg("m2", 200))
val merged = mergeMessages(existing, emptyList())
assertEquals(listOf("m1", "m2"), idsOf(merged))
}
@Test
fun `empty existing returns sorted incoming`() {
val incoming = listOf(msg("m3", 300), msg("m1", 100))
val merged = mergeMessages(emptyList(), incoming)
assertEquals(listOf("m1", "m3"), idsOf(merged))
}
@Test
fun `incoming wins on duplicate id`() {
val existing = listOf(msg("m1", 100, content = "old"))
val incoming = listOf(msg("m1", 100, content = "new"))
val merged = mergeMessages(existing, incoming)
assertEquals(1, merged.size)
assertEquals("new", merged.single().content)
}
@Test
fun `merge does not mutate inputs`() {
val existing = listOf(msg("m2", 200), msg("m1", 100))
val incoming = listOf(msg("m3", 300))
mergeMessages(existing, incoming)
assertEquals(listOf("m2", "m1"), idsOf(existing))
assertEquals(listOf("m3"), idsOf(incoming))
}
}
@@ -1,71 +0,0 @@
package top.yeij.cyrene.domain.admin
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** 管理页权限矩阵(api-client §2.0)的客户端降级判定。 */
class AdminAccessTest {
@Test
fun `owner can use every page`() {
AdminPage.entries.forEach { page ->
assertTrue(page.name, canAccess(AdminRole.Owner, page))
}
}
@Test
fun `admin can use every page`() {
AdminPage.entries.forEach { page ->
assertTrue(page.name, canAccess(AdminRole.Admin, page))
}
}
@Test
fun `viewer can read monitor pages only`() {
assertTrue(canAccess(AdminRole.Viewer, AdminPage.Overview))
assertTrue(canAccess(AdminRole.Viewer, AdminPage.Personas))
}
@Test
fun `viewer cannot read memories or backthink`() {
assertFalse(canAccess(AdminRole.Viewer, AdminPage.Memories))
assertFalse(canAccess(AdminRole.Viewer, AdminPage.Backthink))
}
@Test
fun `unknown role degrades to viewer`() {
assertEquals(AdminRole.Viewer, AdminRole.of("superowner"))
assertFalse(canAccess(AdminRole.of("superowner"), AdminPage.Memories))
}
@Test
fun `null and blank role degrade to viewer`() {
assertEquals(AdminRole.Viewer, AdminRole.of(null))
assertEquals(AdminRole.Viewer, AdminRole.of(" "))
}
@Test
fun `role parsing tolerates case and surrounding blanks`() {
assertEquals(AdminRole.Owner, AdminRole.of(" OWNER "))
assertEquals(AdminRole.Admin, AdminRole.of("Admin"))
assertEquals(AdminRole.Viewer, AdminRole.of("viewer"))
}
@Test
fun `available pages follow the declared order`() {
assertEquals(
listOf(AdminPage.Overview, AdminPage.Personas),
availablePages(AdminRole.Viewer),
)
assertEquals(AdminPage.entries.toList(), availablePages(AdminRole.Owner))
}
@Test
fun `monitor tier is weaker than owner admin tier`() {
assertTrue(AdminTier.Monitor.rank < AdminTier.OwnerAdmin.rank)
assertTrue(AdminRole.Viewer.rank < AdminRole.Admin.rank)
assertTrue(AdminRole.Admin.rank < AdminRole.Owner.rank)
}
}
@@ -1,124 +0,0 @@
package top.yeij.cyrene.domain.admin
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 两种时间形状 + 未知枚举回落 + 三态展示。
*
* 时间断言刻意不做时区假设:只比较**同一时刻的两种写法**是否归一,以及是否落成
* `yyyy-MM-dd HH:mm:ss` 形态,因此在任何时区的机器上都成立。
*/
class AdminFormatTest {
private val timestamp = Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}""")
@Test
fun `offset and zulu spellings of the same instant agree`() {
assertEquals(
formatRfc3339("2026-03-04T04:34:56Z"),
formatRfc3339("2026-03-04T12:34:56+08:00"),
)
}
@Test
fun `rfc3339 with nanosecond precision parses`() {
assertEquals(
formatRfc3339("2026-03-04T04:34:56Z"),
formatRfc3339("2026-03-04T04:34:56.123456789Z"),
)
}
@Test
fun `rfc3339 renders as a local timestamp`() {
val formatted = formatRfc3339("2026-03-04T04:34:56Z")!!
assertTrue(formatted, timestamp.matches(formatted))
}
@Test
fun `unparsable rfc3339 is returned raw instead of dropped`() {
assertEquals("not-a-time", formatRfc3339("not-a-time"))
}
@Test
fun `blank rfc3339 becomes null`() {
assertNull(formatRfc3339(null))
assertNull(formatRfc3339(" "))
}
@Test
fun `backthink millis use the epoch-millis parser, not the rfc3339 one`() {
// 同一时刻的两种形状:Unix 毫秒 1772598896000 = 2026-03-04T04:34:56Z
assertEquals(
formatRfc3339("2026-03-04T04:34:56Z"),
formatEpochMillis(1_772_598_896_000L),
)
}
@Test
fun `nanos pick a unit by magnitude`() {
assertEquals("512 ns", formatNanos(512))
assertEquals("1.5 µs", formatNanos(1_500))
assertEquals("2.5 ms", formatNanos(2_500_000))
assertEquals("1.25 s", formatNanos(1_250_000_000))
}
@Test
fun `counts are sorted by hits descending`() {
assertEquals("GET 5 · POST 2", formatCounts(mapOf("POST" to 2L, "GET" to 5L)))
}
@Test
fun `counts keep unknown keys verbatim`() {
assertEquals("999 1", formatCounts(mapOf("999" to 1L)))
}
@Test
fun `whole doubles lose their decimals`() {
assertEquals("12", formatDouble(12.0))
assertEquals("0", formatDouble(0.0))
assertEquals("1.5", formatDouble(1.5))
}
@Test
fun `unknown enum values render raw`() {
assertEquals("LLM 调用", traceKindLabel("llm_call"))
assertEquals("quantum_hop", traceKindLabel("quantum_hop"))
assertEquals("成功", traceStatusLabel("ok"))
assertEquals("degraded", traceStatusLabel("degraded"))
assertEquals("对话", personaModeLabel("chat"))
assertEquals("telepathy", personaModeLabel("telepathy"))
assertEquals("人格", memoryScopeLabel("persona"))
assertEquals("shared", memoryScopeLabel("shared"))
assertEquals("平静", moodLabel("calm"))
assertEquals("euphoric", moodLabel("euphoric"))
}
@Test
fun `showToUser keeps three distinct states`() {
assertEquals("未配置", triStateLabel(null))
assertEquals("显示", triStateLabel(true))
assertEquals("不显示", triStateLabel(false))
}
@Test
fun `props are rendered as compact json without losing keys`() {
assertEquals(
"""{"model":"gpt"}""",
formatProps(mapOf<String, JsonElement>("model" to JsonPrimitive("gpt"))),
)
val two = formatProps(
mapOf<String, JsonElement>(
"a" to JsonPrimitive(1),
"b" to JsonPrimitive(2),
),
)!!
assertTrue(two, two.contains("\"a\":1") && two.contains("\"b\":2"))
assertNull(formatProps(emptyMap()))
assertNull(formatProps(null))
}
}
@@ -1,350 +0,0 @@
package top.yeij.cyrene.domain.parse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.random.Random
/**
* ChatContentParser 单测:覆盖气泡切分、旁白依附、漂移兼容、畸形回落与流式边界。
* 规则来源见 `devdocs/03-客户端细化开发方案.md` §5 与 Web `parseContent.ts`。
*/
class ChatContentParserTest {
// ------------------------------------------------------------------
// 气泡切分
// ------------------------------------------------------------------
@Test
fun multipleParagraphTagsBecomeSeparateBubbles() {
val bubbles = parse("<p>第一</p><p>第二</p>")
assertEquals(listOf("第一", "第二"), bubbles.map { it.body })
}
@Test
fun blankLineFallbackSplitsBubbles() {
val bubbles = parse("第一段\n\n第二段")
assertEquals(listOf("第一段", "第二段"), bubbles.map { it.body })
}
@Test
fun blankLineWithWhitespaceAlsoSplits() {
val bubbles = parse("第一段\n \n第二段")
assertEquals(listOf("第一段", "第二段"), bubbles.map { it.body })
}
@Test
fun singleNewlineDoesNotSplit() {
assertEquals(listOf("第一行\n第二行"), parse("第一行\n第二行").map { it.body })
assertEquals(listOf("第一行\n第二行"), parse("<p>第一行\n第二行</p>").map { it.body })
}
@Test
fun emptyAndWhitespaceInputYieldsNoBubbles() {
assertTrue(parse("").isEmpty())
assertTrue(parse(" ").isEmpty())
assertTrue(parse("<p> </p>").isEmpty())
}
@Test
fun unclosedParagraphStaysLiteralText() {
assertEquals(listOf("<p>第一"), parse("<p>第一").map { it.body })
}
@Test
fun textOutsideParagraphsIsPreserved() {
val bubbles = parse("<action>笑</action><p>你好</p>")
assertEquals(1, bubbles.size)
assertEquals("你好", bubbles[0].body)
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].head)
}
@Test
fun trailingActionOutsideParagraphsAttachesToTail() {
val bubbles = parse("<p>你好</p><action>笑</action>")
assertEquals(1, bubbles.size)
assertEquals("你好", bubbles[0].body)
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].tail)
}
// ------------------------------------------------------------------
// 旁白依附:首部 → head,尾部 → tail,中间 → 上提 head
// ------------------------------------------------------------------
@Test
fun leadingNarrationAttachesToHead() {
val bubble = parse("<p><narration>其实很开心</narration>你好</p>").single()
assertEquals("你好", bubble.body)
assertEquals(listOf(ContentPart.Narration("其实很开心")), bubble.head)
assertTrue(bubble.tail.isEmpty())
}
@Test
fun trailingNarrationAttachesToTail() {
val bubble = parse("<p>你好<narration>其实很开心</narration></p>").single()
assertEquals("你好", bubble.body)
assertEquals(listOf(ContentPart.Narration("其实很开心")), bubble.tail)
assertTrue(bubble.head.isEmpty())
}
@Test
fun middleActionLiftsToHead() {
val bubble = parse("<p>开头<action>笑</action>结尾</p>").single()
assertEquals("开头结尾", bubble.body)
assertEquals(listOf(ContentPart.Action("笑")), bubble.head)
assertTrue(bubble.tail.isEmpty())
}
@Test
fun partsKeepOriginalOrder() {
val bubble = parse("<p>甲<action>乙</action>丙</p>").single()
assertEquals(
listOf(ContentPart.Text("甲"), ContentPart.Action("乙"), ContentPart.Text("丙")),
bubble.parts,
)
}
@Test
fun eachParagraphKeepsItsOwnNote() {
val bubbles = parse("<p><action>a</action>一</p><p><action>b</action>二</p>")
assertEquals(listOf("一", "二"), bubbles.map { it.body })
assertEquals(listOf(ContentPart.Action("a")), bubbles[0].head)
assertEquals(listOf(ContentPart.Action("b")), bubbles[1].head)
}
@Test
fun notesOnlyParagraphHasEmptyBody() {
val bubble = parse("<p><action>弯起眼睛笑了</action></p>").single()
assertEquals("", bubble.body)
assertEquals(listOf(ContentPart.Action("弯起眼睛笑了")), bubble.head)
assertFalse(bubble.isEmpty)
}
@Test
fun notesOnlyMessageWithoutParagraphTag() {
val bubble = parse("<action>轻轻拉住你的手</action>").single()
assertEquals("", bubble.body)
assertEquals(listOf(ContentPart.Action("轻轻拉住你的手")), bubble.head)
}
@Test
fun blankTextBeforeNoteStillRendersTheNote() {
val bubble = parse("<p> <action>笑</action></p>").single()
assertEquals("", bubble.body)
assertEquals(listOf(ContentPart.Action("笑")), bubble.head + bubble.tail)
}
// ------------------------------------------------------------------
// 漂移兼容:全角 / 半角星号
// ------------------------------------------------------------------
@Test
fun shortFullWidthStarWithoutKeywordBecomesAction() {
val bubble = parse("<p>你好*弯起眼睛*</p>").single()
assertEquals("你好", bubble.body)
assertEquals(listOf(ContentPart.Action("弯起眼睛")), bubble.tail)
}
@Test
fun fullWidthStarWithNarrationKeywordBecomesNarration() {
val bubble = parse("<p>*旁白:心想*</p>").single()
assertEquals("", bubble.body)
assertEquals(listOf(ContentPart.Narration("心想")), bubble.head)
}
@Test
fun fullWidthStarWithActionKeywordBecomesAction() {
val bubble = parse("<p>*动作:笑*</p>").single()
assertEquals(listOf(ContentPart.Action("笑")), bubble.head)
}
@Test
fun fullWidthStarWithInnerKeywordBecomesNarration() {
val bubble = parse("<p>*内心:有点开心*</p>").single()
assertEquals(listOf(ContentPart.Narration("有点开心")), bubble.head)
}
@Test
fun longFullWidthStarStaysText() {
val long = "a".repeat(41)
val bubble = parse("<p>你好*$long*</p>").single()
assertTrue(bubble.head.isEmpty())
assertTrue(bubble.tail.isEmpty())
assertEquals("你好*$long*", bubble.body)
}
@Test
fun multiLineFullWidthStarStaysText() {
val bubble = parse("<p>你好*弯起\n眼睛*</p>").single()
assertTrue(bubble.head.isEmpty())
assertTrue(bubble.tail.isEmpty())
assertTrue(bubble.body.contains("*弯起\n眼睛*"))
}
@Test
fun halfWidthStarWithKeywordBecomesAction() {
val bubble = parse("<p>你好*动作:笑*</p>").single()
assertEquals("你好", bubble.body)
assertEquals(listOf(ContentPart.Action("笑")), bubble.tail)
}
@Test
fun halfWidthStarWithNarrationKeywordBecomesNarration() {
val bubble = parse("<p>*内心:有点开心*你好</p>").single()
assertEquals("你好", bubble.body)
assertEquals(listOf(ContentPart.Narration("有点开心")), bubble.head)
}
@Test
fun halfWidthStarWithoutKeywordStaysMarkdownText() {
val bubble = parse("<p>这是*强调*文字</p>").single()
assertEquals("这是*强调*文字", bubble.body)
assertTrue(bubble.head.isEmpty())
assertTrue(bubble.tail.isEmpty())
}
@Test
fun halfWidthDoubleStarStaysMarkdownText() {
val bubble = parse("<p>这是**加粗**文字</p>").single()
assertEquals("这是**加粗**文字", bubble.body)
assertTrue(bubble.head.isEmpty())
assertTrue(bubble.tail.isEmpty())
}
// ------------------------------------------------------------------
// 畸形 / 未闭合:一律回落文本,绝不抛异常
// ------------------------------------------------------------------
@Test
fun unclosedActionFallsBackToText() {
val bubble = parse("<p>你好<action>笑</p>").single()
assertEquals("你好<action>笑", bubble.body)
assertTrue(bubble.parts.none { it is ContentPart.Action })
}
@Test
fun unclosedNarrationFallsBackToText() {
val bubble = parse("<p>你好<narration>心里暖暖的</p>").single()
assertEquals("你好<narration>心里暖暖的", bubble.body)
assertTrue(bubble.parts.none { it is ContentPart.Narration })
}
@Test
fun strayClosingTagStaysText() {
val bubble = parse("<p>你好</action></p>").single()
assertEquals("你好</action>", bubble.body)
assertTrue(bubble.head.isEmpty())
assertTrue(bubble.tail.isEmpty())
}
@Test
fun mismatchedTagsDoNotThrow() {
val bubbles = parse("<p><action>笑<narration>内心</action></p>")
assertEquals(1, bubbles.size)
assertTrue(bubbles[0].parts.any { it is ContentPart.Action })
}
// ------------------------------------------------------------------
// 流式增量:最终结果必须与一次性解析一致
// ------------------------------------------------------------------
private val sampleText = """
<p><action>轻轻拉住你的手</action>亲爱的,今天过得怎么样?♪</p>
<p>人家给你留了灯呢。</p>
<p>第一行
第二行</p>
<p>你好*弯起眼睛*</p>
<p>这是*强调*文字</p>
<p>*旁白:其实偷偷开心了一下*</p>
<p>晚安。</p>
""".trimIndent()
@Test
fun streamingCharByCharMatchesOneShot() {
val expected = parse(sampleText)
val parser = StreamingContentParser()
for (ch in sampleText) {
parser.onDelta(ch.toString())
}
assertEquals(expected, parser.finish())
}
@Test
fun streamingRandomChunksMatchOneShot() {
val expected = parse(sampleText)
val random = Random(20260908)
repeat(25) {
val parser = StreamingContentParser()
var index = 0
while (index < sampleText.length) {
val size = 1 + random.nextInt(7)
val end = minOf(index + size, sampleText.length)
parser.onDelta(sampleText.substring(index, end))
index = end
}
assertEquals(expected, parser.finish())
}
}
@Test
fun streamingStableInputEqualsOneShotBeforeFinish() {
val text = "<p>你好</p><p><action>笑</action>再见</p>"
val parser = StreamingContentParser()
assertEquals(parse(text), parser.onDelta(text))
}
@Test
fun streamingWithholdsPartialTag() {
val parser = StreamingContentParser()
val partial = parser.onDelta("<p>你好</p><act")
assertTrue(partial.none { it.body.contains("<act") })
assertTrue(partial.none { bubble -> bubble.parts.any { it.text.contains("<act") } })
parser.onDelta("ion>笑</action>")
assertEquals(parse("<p>你好</p><action>笑</action>"), parser.finish())
}
@Test
fun streamingWithholdsUnclosedParagraph() {
val parser = StreamingContentParser()
val first = parser.onDelta("<p>第一</p><p>第二")
assertEquals(listOf("第一"), first.map { it.body })
val second = parser.onDelta("</p>")
assertEquals(listOf("第一", "第二"), second.map { it.body })
}
@Test
fun streamingUnclosedActionFallsBackOnFinish() {
val parser = StreamingContentParser()
parser.onDelta("<p>你好<action>笑")
val done = parser.finish()
assertEquals(1, done.size)
// 未闭合的 <p> 与 <action> 一并回落为字面文本(与一次性解析一致)。
assertEquals("<p>你好<action>笑", done[0].body)
assertTrue(done[0].parts.none { it is ContentPart.Action })
}
@Test
fun streamingMalformedInputNeverThrows() {
val parser = StreamingContentParser()
val chunks = listOf(
"<", "p", ">", "你", "<act", "ion", ">", "笑",
"</", "act", "ion", ">", "</p", ">", "*", "动",
)
for (chunk in chunks) {
parser.onDelta(chunk)
}
assertTrue(parser.finish().isNotEmpty())
}
@Test
fun resetClearsBuffer() {
val parser = StreamingContentParser()
parser.onDelta("<p>旧</p>")
parser.reset()
assertTrue(parser.onDelta("").isEmpty())
assertEquals(listOf("新"), parser.onDelta("<p>新</p>").map { it.body })
}
}
-4
View File
@@ -1,4 +0,0 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}
-9
View File
@@ -1,9 +0,0 @@
org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=false
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
-52
View File
@@ -1,52 +0,0 @@
[versions]
agp = "9.2.1"
kotlin = "2.4.0"
composeBom = "2026.05.01"
activityCompose = "1.13.0"
coreKtx = "1.19.0"
lifecycle = "2.11.0"
miuix = "0.9.3"
retrofit = "3.0.0"
okhttp = "5.5.0"
kotlinxSerialization = "1.11.0"
coroutines = "1.11.0"
securityCrypto = "1.1.0"
junit = "4.13.2"
datastore = "1.2.1"
coil = "3.6.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
miuix-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-ui-android", version.ref = "miuix" }
miuix-icons = { group = "top.yukonga.miuix.kmp", name = "miuix-icons-android", version.ref = "miuix" }
miuix-preference = { group = "top.yukonga.miuix.kmp", name = "miuix-preference-android", version.ref = "miuix" }
miuix-blur = { group = "top.yukonga.miuix.kmp", name = "miuix-blur-android", version.ref = "miuix" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
-8
View File
@@ -1,8 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
# 本机在国内,走腾讯镜像;如需还原官方源,改回 https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.7.1-bin.zip
networkTimeout=60000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
-252
View File
@@ -1,252 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# 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
#
# https://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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
-94
View File
@@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-23
View File
@@ -1,23 +0,0 @@
pluginManagement {
repositories {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
maven("https://maven.aliyun.com/repository/gradle-plugin")
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
google()
mavenCentral()
}
}
rootProject.name = "Cyrene-For-Android"
include(":app")