From 84de51c7b7b32a83a5650f3e546597da3fd17355 Mon Sep 17 00:00:00 2001
From: AskaEth
Date: Tue, 8 Sep 2026 20:40:58 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20Android?=
=?UTF-8?q?=20=E5=AE=A2=E6=88=B7=E7=AB=AF=EF=BC=88M0=20=E9=AA=A8=E6=9E=B6?=
=?UTF-8?q?=20+=20M1=20=E7=99=BB=E5=BD=95=20+=20M2=20=E5=9F=BA=E7=A1=80?=
=?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
M0:Gradle 9.7.1 / AGP 9.2.1 / Kotlin 2.4 / Miuix 0.9.3,minSdk 33,compileSdk 37。
M1:登录页(服务器地址可配、服务端/本地错误分流、EncryptedSharedPreferences、自动登录与静默重登、401 登出)。
M2 基础模块:消息内容解析器(
// + 流式增量)、WebSocket 客户端、REST 数据层与分页去重。
45 个单元测试全绿,真机验证通过(登录页 + 启动图标)。
devdocs/ 收录设计文档、前后端协同事项与细化方案。
---
.gitignore | 9 +
app/build.gradle.kts | 75 +++
app/proguard-rules.pro | 10 +
app/src/main/AndroidManifest.xml | 24 +
.../top/yeij/cyrene/app/AppContainer.kt | 11 +
.../kotlin/top/yeij/cyrene/app/CyreneApp.kt | 27 ++
.../top/yeij/cyrene/app/CyreneApplication.kt | 14 +
.../top/yeij/cyrene/app/MainActivity.kt | 20 +
.../top/yeij/cyrene/core/auth/AuthManager.kt | 120 +++++
.../top/yeij/cyrene/core/auth/SessionStore.kt | 78 +++
.../top/yeij/cyrene/core/error/CyreneError.kt | 51 ++
.../top/yeij/cyrene/core/theme/CyreneTheme.kt | 16 +
.../yeij/cyrene/data/api/ApiClientFactory.kt | 81 ++++
.../top/yeij/cyrene/data/api/ApiEnvelope.kt | 21 +
.../yeij/cyrene/data/api/ApiErrorMapper.kt | 76 +++
.../top/yeij/cyrene/data/api/CyreneApi.kt | 13 +
.../top/yeij/cyrene/data/api/SessionsApi.kt | 44 ++
.../top/yeij/cyrene/data/api/dto/LoginDto.kt | 16 +
.../yeij/cyrene/data/api/dto/MessageDto.kt | 24 +
.../yeij/cyrene/data/api/dto/SessionDto.kt | 36 ++
.../data/repository/MessageRepository.kt | 61 +++
.../cyrene/data/repository/PagedMessages.kt | 40 ++
.../data/repository/SessionRepository.kt | 35 ++
.../top/yeij/cyrene/data/ws/ChatSocket.kt | 254 ++++++++++
.../kotlin/top/yeij/cyrene/data/ws/WsEvent.kt | 459 ++++++++++++++++++
.../top/yeij/cyrene/data/ws/WsOutbound.kt | 118 +++++
.../kotlin/top/yeij/cyrene/data/ws/WsState.kt | 25 +
.../cyrene/domain/parse/ChatContentParser.kt | 409 ++++++++++++++++
.../yeij/cyrene/ui/common/LoadingScreen.kt | 25 +
.../top/yeij/cyrene/ui/home/HomeScreen.kt | 52 ++
.../top/yeij/cyrene/ui/login/LoginScreen.kt | 143 ++++++
.../yeij/cyrene/ui/login/LoginViewModel.kt | 73 +++
.../res/mipmap-anydpi-v26/ic_launcher.xml | 5 +
.../mipmap-anydpi-v26/ic_launcher_round.xml | 5 +
app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 13366 bytes
.../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 23859 bytes
.../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 13366 bytes
app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 6462 bytes
.../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 11644 bytes
.../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 6462 bytes
app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 22247 bytes
.../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 39575 bytes
.../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 22247 bytes
.../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 45294 bytes
.../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 79476 bytes
.../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 45294 bytes
.../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 74129 bytes
.../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 129487 bytes
.../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 74129 bytes
app/src/main/res/values-night/themes.xml | 6 +
app/src/main/res/values/colors.xml | 4 +
app/src/main/res/values/strings.xml | 4 +
app/src/main/res/values/themes.xml | 6 +
.../data/repository/MessageMergeTest.kt | 88 ++++
.../domain/parse/ChatContentParserTest.kt | 350 +++++++++++++
build.gradle.kts | 4 +
devdocs/01-功能拆解与分步路线.md | 90 ++++
devdocs/02-前后端协同事项.md | 207 ++++++++
devdocs/03-客户端细化开发方案.md | 197 ++++++++
devdocs/DESIGN.md | 95 ++++
gradle.properties | 15 +
gradle/libs.versions.toml | 47 ++
gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43504 bytes
gradle/wrapper/gradle-wrapper.properties | 8 +
gradlew | 252 ++++++++++
gradlew.bat | 94 ++++
settings.gradle.kts | 23 +
67 files changed, 3960 insertions(+)
create mode 100644 .gitignore
create mode 100644 app/build.gradle.kts
create mode 100644 app/proguard-rules.pro
create mode 100644 app/src/main/AndroidManifest.xml
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/app/CyreneApplication.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/auth/AuthManager.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/auth/SessionStore.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/error/CyreneError.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/theme/CyreneTheme.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/ApiEnvelope.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/ApiErrorMapper.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/CyreneApi.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/dto/LoginDto.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/repository/PagedMessages.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/ws/WsOutbound.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/ws/WsState.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/common/LoadingScreen.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/home/HomeScreen.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/login/LoginScreen.kt
create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/login/LoginViewModel.kt
create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png
create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png
create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png
create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png
create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png
create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png
create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
create mode 100644 app/src/main/res/values-night/themes.xml
create mode 100644 app/src/main/res/values/colors.xml
create mode 100644 app/src/main/res/values/strings.xml
create mode 100644 app/src/main/res/values/themes.xml
create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt
create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt
create mode 100644 build.gradle.kts
create mode 100644 devdocs/01-功能拆解与分步路线.md
create mode 100644 devdocs/02-前后端协同事项.md
create mode 100644 devdocs/03-客户端细化开发方案.md
create mode 100644 devdocs/DESIGN.md
create mode 100644 gradle.properties
create mode 100644 gradle/libs.versions.toml
create mode 100644 gradle/wrapper/gradle-wrapper.jar
create mode 100644 gradle/wrapper/gradle-wrapper.properties
create mode 100755 gradlew
create mode 100644 gradlew.bat
create mode 100644 settings.gradle.kts
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0e7033b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+*.iml
+.gradle/
+build/
+local.properties
+.idea/
+.DS_Store
+captures/
+.externalNativeBuild/
+.cxx/
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..1b4c775
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,75 @@
+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)
+
+ debugImplementation(libs.androidx.compose.ui.tooling)
+
+ testImplementation(libs.junit)
+}
+
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..7580ffd
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,10 @@
+# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
+-keepattributes *Annotation*, InnerClasses
+-dontnote kotlinx.serialization.**
+
+-keepclassmembers class **$$serializer {
+ *** descriptor;
+}
+-keepclasseswithmembers class ** {
+ kotlinx.serialization.KSerializer serializer(...);
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..eb51a12
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt
new file mode 100644
index 0000000..003c3a5
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt
@@ -0,0 +1,11 @@
+package top.yeij.cyrene.app
+
+import android.content.Context
+import top.yeij.cyrene.core.auth.AuthManager
+import top.yeij.cyrene.core.auth.SessionStore
+
+/** 手写 DI 容器(D:不引 Hilt)。 */
+class AppContainer(context: Context) {
+ val sessionStore: SessionStore = SessionStore(context.applicationContext)
+ val authManager: AuthManager = AuthManager(sessionStore)
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt
new file mode 100644
index 0000000..c8cd05c
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt
@@ -0,0 +1,27 @@
+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.common.LoadingScreen
+import top.yeij.cyrene.ui.home.HomeScreen
+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 -> HomeScreen(
+ onLogout = { container.authManager.logout() },
+ )
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApplication.kt b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApplication.kt
new file mode 100644
index 0000000..7b15398
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApplication.kt
@@ -0,0 +1,14 @@
+package top.yeij.cyrene.app
+
+import android.app.Application
+
+class CyreneApplication : Application() {
+
+ lateinit var container: AppContainer
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+ container = AppContainer(this)
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt b/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt
new file mode 100644
index 0000000..85601a0
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/app/MainActivity.kt
@@ -0,0 +1,20 @@
+package top.yeij.cyrene.app
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import top.yeij.cyrene.core.theme.CyreneTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+ val container = (application as CyreneApplication).container
+ setContent {
+ CyreneTheme {
+ CyreneApp(container)
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/auth/AuthManager.kt b/app/src/main/kotlin/top/yeij/cyrene/core/auth/AuthManager.kt
new file mode 100644
index 0000000..0c36b2a
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/core/auth/AuthManager.kt
@@ -0,0 +1,120 @@
+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.Unknown)
+ val state: StateFlow = _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 IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
+ else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/auth/SessionStore.kt b/app/src/main/kotlin/top/yeij/cyrene/core/auth/SessionStore.kt
new file mode 100644
index 0000000..c8b56c4
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/core/auth/SessionStore.kt
@@ -0,0 +1,78 @@
+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"
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/error/CyreneError.kt b/app/src/main/kotlin/top/yeij/cyrene/core/error/CyreneError.kt
new file mode 100644
index 0000000..4f16ff8
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/core/error/CyreneError.kt
@@ -0,0 +1,51 @@
+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.Parse -> "服务器返回的数据无法识别"
+ LocalKind.Unknown -> "发生未知错误"
+ }
+ }
+
+ enum class LocalKind {
+ InvalidUrl,
+ NoNetwork,
+ Dns,
+ Tls,
+ Timeout,
+ ConnectFailed,
+ Parse,
+ Unknown,
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/theme/CyreneTheme.kt b/app/src/main/kotlin/top/yeij/cyrene/core/theme/CyreneTheme.kt
new file mode 100644
index 0000000..41cfa7c
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/core/theme/CyreneTheme.kt
@@ -0,0 +1,16 @@
+package top.yeij.cyrene.core.theme
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import top.yukonga.miuix.kmp.theme.ColorSchemeMode
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+import top.yukonga.miuix.kmp.theme.ThemeController
+
+@Composable
+fun CyreneTheme(
+ mode: ColorSchemeMode = ColorSchemeMode.System,
+ content: @Composable () -> Unit,
+) {
+ val controller = remember(mode) { ThemeController(mode) }
+ MiuixTheme(controller = controller, content = content)
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt
new file mode 100644
index 0000000..a43da49
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt
@@ -0,0 +1,81 @@
+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
+ }
+
+ fun create(
+ baseUrl: String,
+ tokenProvider: () -> String? = { null },
+ onUnauthorized: () -> Unit = {},
+ ): CyreneApi {
+ 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(CyreneApi::class.java)
+ }
+
+ /** 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
+ }
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiEnvelope.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiEnvelope.kt
new file mode 100644
index 0000000..3594f77
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiEnvelope.kt
@@ -0,0 +1,21 @@
+package top.yeij.cyrene.data.api
+
+import kotlinx.serialization.Serializable
+
+/** 后端统一信封:成功 `{data}`,失败 `{error:{code,message}}`。 */
+@Serializable
+data class ApiEnvelope(
+ 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 = "",
+)
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiErrorMapper.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiErrorMapper.kt
new file mode 100644
index 0000000..64930e7
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiErrorMapper.kt
@@ -0,0 +1,76 @@
+package top.yeij.cyrene.data.api
+
+import top.yeij.cyrene.core.error.CyreneError
+import kotlinx.coroutines.CancellationException
+import kotlinx.serialization.SerializationException
+import retrofit2.Response
+import java.io.IOException
+import java.net.ConnectException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+import javax.net.ssl.SSLException
+
+/**
+ * 统一错误映射(S1.1):已建连接用服务端 `error`,本地异常按类型分类。
+ * 供所有 REST 仓库复用;不要在各个仓库里各写一份。
+ */
+object ApiErrorMapper {
+
+ /** 有 HTTP 响应:401 → [CyreneError.Unauthorized],其余 → [CyreneError.Server]。 */
+ fun fromHttp(httpStatus: Int, errorBody: String?): CyreneError {
+ val error = parseErrorEnvelope(errorBody)
+ val code = error?.code.orEmpty()
+ val message = error?.message.orEmpty()
+ return if (httpStatus == 401) {
+ CyreneError.Unauthorized(code = code, message = message)
+ } else {
+ CyreneError.Server(code = code, message = message, httpStatus = httpStatus)
+ }
+ }
+
+ /** 本地/网络异常 → [CyreneError.Local]。 */
+ fun fromThrowable(throwable: Throwable): CyreneError = when (throwable) {
+ is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
+ is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
+ is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
+ is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
+ is SerializationException -> CyreneError.Local(CyreneError.LocalKind.Parse)
+ is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
+ else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
+ }
+
+ /** 解析 `{"error":{code,message}}`;非信封 / 空白 / 解析失败均返回 null。 */
+ fun parseErrorEnvelope(errorBody: String?): ApiError? = errorBody
+ ?.takeIf { it.isNotBlank() }
+ ?.let {
+ runCatching {
+ ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
+ }.getOrNull()
+ }
+}
+
+/** 把 [CyreneError] 塞进 Kotlin `Result` 的载体,便于 `Result` 统一传递失败。 */
+class CyreneException(val error: CyreneError) : Exception(error.userMessage)
+
+/**
+ * 执行一次信封调用并把结果收敛成 `Result`:
+ * 成功且 `data != null` → success;成功但 `data == null` → Parse;否则走 [ApiErrorMapper]。
+ */
+suspend fun apiCall(block: suspend () -> Response>): Result =
+ 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)))
+ }
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/CyreneApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/CyreneApi.kt
new file mode 100644
index 0000000..31923f0
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/CyreneApi.kt
@@ -0,0 +1,13 @@
+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>
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt
new file mode 100644
index 0000000..41c46ad
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt
@@ -0,0 +1,44 @@
+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>>
+
+ /** 无 body;服务端返回 `201 {data:{sessionId}}`。 */
+ @POST("api/v1/sessions")
+ suspend fun createSession(): Response>
+
+ /** 主会话 `web` 删除会返回 `403 forbidden`。 */
+ @DELETE("api/v1/sessions/{id}")
+ suspend fun deleteSession(@Path("id") sessionId: String): Response>
+
+ /**
+ * 消息热分页,服务端按 `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>>
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/LoginDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/LoginDto.kt
new file mode 100644
index 0000000..de40326
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/LoginDto.kt
@@ -0,0 +1,16 @@
+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,
+)
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt
new file mode 100644
index 0000000..aac1b06
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt
@@ -0,0 +1,24 @@
+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,
+)
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt
new file mode 100644
index 0000000..c28183a
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt
@@ -0,0 +1,36 @@
+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:"}}`。 */
+@Serializable
+data class CreateSessionResponse(
+ val sessionId: String,
+)
+
+/** `DELETE /api/v1/sessions/{id}` → `200 {"data":{"deleted":""}}`。 */
+@Serializable
+data class DeleteSessionResponse(
+ val deleted: String,
+)
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt
new file mode 100644
index 0000000..30fa48f
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt
@@ -0,0 +1,61 @@
+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 = 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 = 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,
+ pageSize: Int,
+): PagedMessages {
+ val ascending = rawNewestFirst.sortedWith(MessageOrder)
+ return PagedMessages(
+ messages = ascending,
+ hasMore = rawNewestFirst.size >= pageSize,
+ oldestCreatedAt = ascending.firstOrNull()?.createdAt,
+ newestCreatedAt = ascending.lastOrNull()?.createdAt,
+ )
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/PagedMessages.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/PagedMessages.kt
new file mode 100644
index 0000000..013b2ae
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/PagedMessages.kt
@@ -0,0 +1,40 @@
+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,
+ val hasMore: Boolean,
+ val oldestCreatedAt: Long?,
+ val newestCreatedAt: Long?,
+)
+
+/** 消息排序:`createdAt` 升序,同毫秒用 `id` 兜底,保证稳定可复现。 */
+internal val MessageOrder: Comparator = compareBy({ 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,
+ incoming: List,
+): List {
+ if (incoming.isEmpty()) return existing.sortedWith(MessageOrder)
+ if (existing.isEmpty()) return incoming.sortedWith(MessageOrder)
+
+ val byId = LinkedHashMap(existing.size + incoming.size)
+ existing.forEach { byId[it.id] = it }
+ incoming.forEach { byId[it.id] = it }
+ return byId.values.sortedWith(MessageOrder)
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt
new file mode 100644
index 0000000..00e505e
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt
@@ -0,0 +1,35 @@
+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> = withContext(dispatcher) {
+ apiCall { api.listSessions() }
+ }
+
+ /** `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:`)。 */
+ suspend fun createSession(): Result = withContext(dispatcher) {
+ apiCall { api.createSession() }.map { it.sessionId }
+ }
+
+ /** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */
+ suspend fun deleteSession(sessionId: String): Result = withContext(dispatcher) {
+ apiCall { api.deleteSession(sessionId) }.map { it.deleted }
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt
new file mode 100644
index 0000000..6e24ef2
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt
@@ -0,0 +1,254 @@
+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=`,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 = _state.asStateFlow()
+
+ private val _events = MutableSharedFlow(
+ 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 = _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()
+ 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"
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt
new file mode 100644
index 0000000..941cceb
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt
@@ -0,0 +1,459 @@
+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-`);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,
+ 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:`)消息落库后广播。 */
+ 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(data).messages, turnId, seq, sessionId)
+ "message.start" -> MessageStart(decode(data).index, turnId, seq)
+ "message.delta" -> decode(data).let { MessageDelta(it.delta, it.index, turnId, seq) }
+ "message.end" -> MessageEnd(decode(data).index, turnId, seq)
+ "thinking.start" -> ThinkingStart(decode(data).index, turnId, seq)
+ "thinking.delta" -> decode(data).let { ThinkingDelta(it.delta, it.index, turnId, seq) }
+ "thinking.end" -> ThinkingEnd(decode(data).index, turnId, seq)
+ "tool_call.start" -> decode(data).let { ToolCallStart(it.id, it.index, turnId, seq) }
+ "tool_call.delta" -> decode(data).let { ToolCallDelta(it.id, it.index, it.delta, turnId, seq) }
+ "tool_call.end" -> decode(data).let { ToolCallEnd(it.id, it.index, turnId, seq) }
+ "tool_execution.update" -> decode(data)
+ .let { ToolExecutionUpdate(it.id, it.index, it.delta, turnId, seq) }
+ "stream_chunk" -> StreamChunk(decode(data).delta, turnId, seq, sessionId)
+ "stream_end" -> decode(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(data)
+ .let { SessionBusy(resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq) }
+ "pending_ack" -> decode(data)
+ .let { PendingAck(resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq) }
+ "pending_drain" -> decode(data)
+ .let { PendingDrain(resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq) }
+ "session_idle" -> decode(data)
+ .let { SessionIdle(resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq) }
+ "session_interrupted" -> decode(data)
+ .let { SessionInterrupted(resolveSessionId(sessionId, it.sessionId), it.mode, it.dropped, turnId, seq) }
+ "pending_full" -> decode(data)
+ .let { PendingFull(resolveSessionId(sessionId, it.sessionId), it.mode, it.max, turnId, seq) }
+ "message_persisted" -> decode(data)
+ .let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
+ "channel_persisted" -> decode(data)
+ .let { ChannelPersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
+ "voice_output" -> decode(data)
+ .let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
+ "proactive_message" -> decode(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(data).message, turnId, seq, sessionId)
+ else -> null
+ }
+
+ private fun resolveSessionId(shell: String?, fromData: String): String =
+ fromData.ifBlank { shell ?: "" }
+
+ private inline fun decode(data: JsonElement?): T =
+ ApiClientFactory.json.decodeFromJsonElement(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 = 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 = "")
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsOutbound.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsOutbound.kt
new file mode 100644
index 0000000..4acc4bd
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsOutbound.kt
@@ -0,0 +1,118 @@
+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) }
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsState.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsState.kt
new file mode 100644
index 0000000..866bc97
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsState.kt
@@ -0,0 +1,25 @@
+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,
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt
new file mode 100644
index 0000000..e18ef66
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt
@@ -0,0 +1,409 @@
+package top.yeij.cyrene.domain.parse
+
+import kotlin.math.min
+
+/*
+ * ChatContentParser —— 纯 Kotlin 的聊天内容解析器(无 Android / Compose 依赖)。
+ *
+ * 权威规则:
+ * - `configs/personas/cyrene.yaml`「分段约定」「表达方式:动作与旁白」;
+ * - `devdocs/03-客户端细化开发方案.md` §5 渲染管线;
+ * - Web 端 `frontend/web/src/lib/parseContent.ts` + `components/chat.tsx`(行为对齐基准)。
+ *
+ * 语义摘要:
+ * - `…
`:每个完整块 = 一条独立气泡;无完整 `` 时回退按空行 `\n\s*\n` 切分;
+ * 单个 `\n` 是句内换行,绝不切分。未闭合的 `
` 不构成块,按普通文本处理(流式下会先缓存)。
+ * `
` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
+ * - `…` / `…`:旁白/动作,不独立成气泡,依附气泡:
+ * 首部连续块 → 挂气泡上方(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) 未闭合的 `` / `` / `` 开标签(从该开标签起扣留);
+ * 2) 尾部残缺标签(如 `,
+ val head: List,
+ val tail: List,
+) {
+ val isEmpty: Boolean
+ get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
+}
+
+/** 完整 `…
` 块(允许 `class` 等属性,但要求标签名后为空白或直接 `>`)。 */
+private val P_BLOCK_RE = Regex("]*)?>([\\s\\S]*?)
")
+
+/**
+ * 一次匹配旁白的三种等价写法;左侧优先(最靠左的匹配获胜),
+ * 三种 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("^]*)?>$")
+private val CLOSE_P_TAG_RE = Regex("^
$")
+
+/** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */
+fun parse(raw: String): List {
+ 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 {
+ if (delta.isNotEmpty()) buffer.append(delta)
+ val current = buffer.toString()
+ return parse(current.substring(0, stablePrefixEnd(current)))
+ }
+
+ /** 收口:对全部缓冲做一次性解析,结果与 `parse(拼接的全部 delta)` 完全一致。 */
+ fun finish(): List = 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
+}
+
+/** 先切 `` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */
+private fun tokenize(raw: String): List {
+ val pMatches = P_BLOCK_RE.findAll(raw).toList()
+ val out = ArrayList()
+ 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, 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)
+}
+
+/** 追加普通文本;无 `` 时按空行插入气泡边界。 */
+private fun appendText(text: String, out: MutableList, 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): List {
+ val groups = ArrayList>()
+ var current = ArrayList()
+ 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>()
+ var carry = ArrayList()
+ for (group in groups) {
+ val combined = ArrayList(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): Boolean =
+ tokens.any { it is Tok.Txt && it.text.isNotBlank() }
+
+/** 构造气泡:合并相邻文本、计算 body,并按首/尾规则分配 head / tail。 */
+private fun toBubble(tokens: List): ParsedBubble? {
+ val parts = ArrayList(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()
+ val tail = ArrayList()
+ 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>()
+ 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) }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/common/LoadingScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/common/LoadingScreen.kt
new file mode 100644
index 0000000..6e1c03e
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/ui/common/LoadingScreen.kt
@@ -0,0 +1,25 @@
+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)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/home/HomeScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/home/HomeScreen.kt
new file mode 100644
index 0000000..ccacd33
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/ui/home/HomeScreen.kt
@@ -0,0 +1,52 @@
+package top.yeij.cyrene.ui.home
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.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.basic.Sidebar
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+
+@Composable
+fun HomeScreen(onLogout: () -> Unit = {}) {
+ Scaffold(
+ topBar = {
+ SmallTopAppBar(
+ title = "Cyrene",
+ navigationIcon = {
+ IconButton(onClick = { /* M5:打开侧边栏 */ }) {
+ Icon(imageVector = MiuixIcons.Basic.Sidebar, contentDescription = "菜单")
+ }
+ },
+ actions = {
+ TextButton("登出", onClick = onLogout)
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(text = "与昔涟", style = MiuixTheme.textStyles.title2)
+ Text(
+ text = "会话列表 · 规划中",
+ style = MiuixTheme.textStyles.footnote1,
+ color = MiuixTheme.colorScheme.onSurfaceContainerVariant,
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/login/LoginScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/login/LoginScreen.kt
new file mode 100644
index 0000000..1337cbe
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/ui/login/LoginScreen.kt
@@ -0,0 +1,143 @@
+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))
+ }
+ }
+}
diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/login/LoginViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/login/LoginViewModel.kt
new file mode 100644
index 0000000..3b817d3
--- /dev/null
+++ b/app/src/main/kotlin/top/yeij/cyrene/ui/login/LoginViewModel.kt
@@ -0,0 +1,73 @@
+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 = _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) }
+ }
+ }
+}
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..be43858
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..be43858
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..d56d30d9b9d3ad14bf4d7c4a16748f23f5ed6061
GIT binary patch
literal 13366
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
M07*qoM6N<$g16%(vj6}9
literal 0
HcmV?d00001
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000000000000000000000000000000000..5f0322cdd637fa0fc22f723a02e63b2b734e063d
GIT binary patch
literal 23859
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