commit 84de51c7b7b32a83a5650f3e546597da3fd17355
Author: AskaEth
Date: Tue Sep 8 20:40:58 2026 +0800
feat: 初始化 Android 客户端(M0 骨架 + M1 登录 + M2 基础模块)
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/ 收录设计文档、前后端协同事项与细化方案。
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 0000000..d56d30d
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
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 0000000..5f0322c
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..d56d30d
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..1e83334
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..752283e
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..1e83334
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..24fd6bb
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..d7700ec
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..24fd6bb
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..81a7f87
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..d1ae396
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..81a7f87
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d79812b
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..8dfccca
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..d79812b
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..ae22d23
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..99f8ab8
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #F8F8F8
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..8c48794
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Cyrene
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..6a3bbf6
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt
new file mode 100644
index 0000000..c8ca355
--- /dev/null
+++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt
@@ -0,0 +1,88 @@
+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) = 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))
+ }
+}
diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt
new file mode 100644
index 0000000..6b3e9ef
--- /dev/null
+++ b/app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt
@@ -0,0 +1,350 @@
+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("第一
第二
")
+ 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("第一行\n第二行
").map { it.body })
+ }
+
+ @Test
+ fun emptyAndWhitespaceInputYieldsNoBubbles() {
+ assertTrue(parse("").isEmpty())
+ assertTrue(parse(" ").isEmpty())
+ assertTrue(parse("
").isEmpty())
+ }
+
+ @Test
+ fun unclosedParagraphStaysLiteralText() {
+ assertEquals(listOf("第一"), parse("
第一").map { it.body })
+ }
+
+ @Test
+ fun textOutsideParagraphsIsPreserved() {
+ val bubbles = parse("笑
你好
")
+ assertEquals(1, bubbles.size)
+ assertEquals("你好", bubbles[0].body)
+ assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].head)
+ }
+
+ @Test
+ fun trailingActionOutsideParagraphsAttachesToTail() {
+ val bubbles = parse("你好
笑")
+ assertEquals(1, bubbles.size)
+ assertEquals("你好", bubbles[0].body)
+ assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].tail)
+ }
+
+ // ------------------------------------------------------------------
+ // 旁白依附:首部 → head,尾部 → tail,中间 → 上提 head
+ // ------------------------------------------------------------------
+
+ @Test
+ fun leadingNarrationAttachesToHead() {
+ val bubble = parse("其实很开心你好
").single()
+ assertEquals("你好", bubble.body)
+ assertEquals(listOf(ContentPart.Narration("其实很开心")), bubble.head)
+ assertTrue(bubble.tail.isEmpty())
+ }
+
+ @Test
+ fun trailingNarrationAttachesToTail() {
+ val bubble = parse("你好其实很开心
").single()
+ assertEquals("你好", bubble.body)
+ assertEquals(listOf(ContentPart.Narration("其实很开心")), bubble.tail)
+ assertTrue(bubble.head.isEmpty())
+ }
+
+ @Test
+ fun middleActionLiftsToHead() {
+ val bubble = parse("开头笑结尾
").single()
+ assertEquals("开头结尾", bubble.body)
+ assertEquals(listOf(ContentPart.Action("笑")), bubble.head)
+ assertTrue(bubble.tail.isEmpty())
+ }
+
+ @Test
+ fun partsKeepOriginalOrder() {
+ val bubble = parse("甲乙丙
").single()
+ assertEquals(
+ listOf(ContentPart.Text("甲"), ContentPart.Action("乙"), ContentPart.Text("丙")),
+ bubble.parts,
+ )
+ }
+
+ @Test
+ fun eachParagraphKeepsItsOwnNote() {
+ val bubbles = parse("a一
b二
")
+ 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("弯起眼睛笑了
").single()
+ assertEquals("", bubble.body)
+ assertEquals(listOf(ContentPart.Action("弯起眼睛笑了")), bubble.head)
+ assertFalse(bubble.isEmpty)
+ }
+
+ @Test
+ fun notesOnlyMessageWithoutParagraphTag() {
+ val bubble = parse("轻轻拉住你的手").single()
+ assertEquals("", bubble.body)
+ assertEquals(listOf(ContentPart.Action("轻轻拉住你的手")), bubble.head)
+ }
+
+ @Test
+ fun blankTextBeforeNoteStillRendersTheNote() {
+ val bubble = parse(" 笑
").single()
+ assertEquals("", bubble.body)
+ assertEquals(listOf(ContentPart.Action("笑")), bubble.head + bubble.tail)
+ }
+
+ // ------------------------------------------------------------------
+ // 漂移兼容:全角 / 半角星号
+ // ------------------------------------------------------------------
+
+ @Test
+ fun shortFullWidthStarWithoutKeywordBecomesAction() {
+ val bubble = parse("你好*弯起眼睛*
").single()
+ assertEquals("你好", bubble.body)
+ assertEquals(listOf(ContentPart.Action("弯起眼睛")), bubble.tail)
+ }
+
+ @Test
+ fun fullWidthStarWithNarrationKeywordBecomesNarration() {
+ val bubble = parse("*旁白:心想*
").single()
+ assertEquals("", bubble.body)
+ assertEquals(listOf(ContentPart.Narration("心想")), bubble.head)
+ }
+
+ @Test
+ fun fullWidthStarWithActionKeywordBecomesAction() {
+ val bubble = parse("*动作:笑*
").single()
+ assertEquals(listOf(ContentPart.Action("笑")), bubble.head)
+ }
+
+ @Test
+ fun fullWidthStarWithInnerKeywordBecomesNarration() {
+ val bubble = parse("*内心:有点开心*
").single()
+ assertEquals(listOf(ContentPart.Narration("有点开心")), bubble.head)
+ }
+
+ @Test
+ fun longFullWidthStarStaysText() {
+ val long = "a".repeat(41)
+ val bubble = parse("你好*$long*
").single()
+ assertTrue(bubble.head.isEmpty())
+ assertTrue(bubble.tail.isEmpty())
+ assertEquals("你好*$long*", bubble.body)
+ }
+
+ @Test
+ fun multiLineFullWidthStarStaysText() {
+ val bubble = parse("你好*弯起\n眼睛*
").single()
+ assertTrue(bubble.head.isEmpty())
+ assertTrue(bubble.tail.isEmpty())
+ assertTrue(bubble.body.contains("*弯起\n眼睛*"))
+ }
+
+ @Test
+ fun halfWidthStarWithKeywordBecomesAction() {
+ val bubble = parse("你好*动作:笑*
").single()
+ assertEquals("你好", bubble.body)
+ assertEquals(listOf(ContentPart.Action("笑")), bubble.tail)
+ }
+
+ @Test
+ fun halfWidthStarWithNarrationKeywordBecomesNarration() {
+ val bubble = parse("*内心:有点开心*你好
").single()
+ assertEquals("你好", bubble.body)
+ assertEquals(listOf(ContentPart.Narration("有点开心")), bubble.head)
+ }
+
+ @Test
+ fun halfWidthStarWithoutKeywordStaysMarkdownText() {
+ val bubble = parse("这是*强调*文字
").single()
+ assertEquals("这是*强调*文字", bubble.body)
+ assertTrue(bubble.head.isEmpty())
+ assertTrue(bubble.tail.isEmpty())
+ }
+
+ @Test
+ fun halfWidthDoubleStarStaysMarkdownText() {
+ val bubble = parse("这是**加粗**文字
").single()
+ assertEquals("这是**加粗**文字", bubble.body)
+ assertTrue(bubble.head.isEmpty())
+ assertTrue(bubble.tail.isEmpty())
+ }
+
+ // ------------------------------------------------------------------
+ // 畸形 / 未闭合:一律回落文本,绝不抛异常
+ // ------------------------------------------------------------------
+
+ @Test
+ fun unclosedActionFallsBackToText() {
+ val bubble = parse("你好笑
").single()
+ assertEquals("你好笑", bubble.body)
+ assertTrue(bubble.parts.none { it is ContentPart.Action })
+ }
+
+ @Test
+ fun unclosedNarrationFallsBackToText() {
+ val bubble = parse("你好心里暖暖的
").single()
+ assertEquals("你好心里暖暖的", bubble.body)
+ assertTrue(bubble.parts.none { it is ContentPart.Narration })
+ }
+
+ @Test
+ fun strayClosingTagStaysText() {
+ val bubble = parse("你好
").single()
+ assertEquals("你好
", bubble.body)
+ assertTrue(bubble.head.isEmpty())
+ assertTrue(bubble.tail.isEmpty())
+ }
+
+ @Test
+ fun mismatchedTagsDoNotThrow() {
+ val bubbles = parse("
笑内心
")
+ assertEquals(1, bubbles.size)
+ assertTrue(bubbles[0].parts.any { it is ContentPart.Action })
+ }
+
+ // ------------------------------------------------------------------
+ // 流式增量:最终结果必须与一次性解析一致
+ // ------------------------------------------------------------------
+
+ private val sampleText = """
+ 轻轻拉住你的手亲爱的,今天过得怎么样?♪
+ 人家给你留了灯呢。
+ 第一行
+ 第二行
+ 你好*弯起眼睛*
+ 这是*强调*文字
+ *旁白:其实偷偷开心了一下*
+ 晚安。
+ """.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 = "你好
笑再见
"
+ val parser = StreamingContentParser()
+ assertEquals(parse(text), parser.onDelta(text))
+ }
+
+ @Test
+ fun streamingWithholdsPartialTag() {
+ val parser = StreamingContentParser()
+ val partial = parser.onDelta("你好
bubble.parts.any { it.text.contains("笑
")
+ assertEquals(parse("
你好
笑"), parser.finish())
+ }
+
+ @Test
+ fun streamingWithholdsUnclosedParagraph() {
+ val parser = StreamingContentParser()
+ val first = parser.onDelta("第一
第二")
+ assertEquals(listOf("第一"), first.map { it.body })
+
+ val second = parser.onDelta("
")
+ assertEquals(listOf("第一", "第二"), second.map { it.body })
+ }
+
+ @Test
+ fun streamingUnclosedActionFallsBackOnFinish() {
+ val parser = StreamingContentParser()
+ parser.onDelta("你好笑")
+ val done = parser.finish()
+ assertEquals(1, done.size)
+ // 未闭合的 与 一并回落为字面文本(与一次性解析一致)。
+ assertEquals("你好笑", done[0].body)
+ assertTrue(done[0].parts.none { it is ContentPart.Action })
+ }
+
+ @Test
+ fun streamingMalformedInputNeverThrows() {
+ val parser = StreamingContentParser()
+ val chunks = listOf(
+ "<", "p", ">", "你", "", "笑",
+ "", "act", "ion", ">", "
", "*", "动",
+ )
+ for (chunk in chunks) {
+ parser.onDelta(chunk)
+ }
+ assertTrue(parser.finish().isNotEmpty())
+ }
+
+ @Test
+ fun resetClearsBuffer() {
+ val parser = StreamingContentParser()
+ parser.onDelta("旧
")
+ parser.reset()
+ assertTrue(parser.onDelta("").isEmpty())
+ assertEquals(listOf("新"), parser.onDelta("新
").map { it.body })
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..020183f
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
diff --git a/devdocs/01-功能拆解与分步路线.md b/devdocs/01-功能拆解与分步路线.md
new file mode 100644
index 0000000..5bafa5e
--- /dev/null
+++ b/devdocs/01-功能拆解与分步路线.md
@@ -0,0 +1,90 @@
+# 01 — 功能拆解与分步路线(Cyrene-For-Android 讨论稿 v1)
+
+> 配套 `DESIGN.md`(总览)。本文按**分步**展开:每模块给出「做什么 / 交互 / 数据流 / 依赖 / 验收 / 边界 / Miuix 组件映射」。状态:**讨论稿**,与主人在本会话逐模块细化后定稿。
+> 前提(已定案):主界面=常规 AI APP;长按电源键=系统默认语音助手(VOICE_INTERACTION_SERVICE,全屏,无悬浮球);ASR/TTS 后端化;先纯文本版;UI 严格 Miuix(compose-miuix-ui)。
+
+---
+
+## 阶段 0 · 工程骨架
+**S0.1 工程搭建**
+- Gradle(版本目录)、Kotlin + Compose、`compose-miuix-ui` 依赖、`BuildConfig.BASE_URL`(debug `http://:8080`,release 换 HTTPS)、单 Module `app`、包 `com.askaeth.cyrene.{app,core,data,domain,platform,ui}`。
+- 依赖:Retrofit+OkHttp、kotlinx-serialization、okhttp-WebSocket、EncryptedSharedPreferences、Hilt(可选)、Coil(图片)。
+- 验收:`./gradlew :app:assembleDebug` 通过,App 能起空壳 Miuix 首页。
+- 边界:暂不接后端。
+
+## 阶段 1 · P0 纯文本聊天(零后端改动,契约现成)
+### S1.1 登录(已定稿 2026-09-08)
+- **服务器地址可配**(开源,各人部署地址不同):登录卡放「服务器地址」折叠项/入口,默认 `http://:8080`,用户可改;存本地(EncryptedPrefs/DataStore)。
+- **仅 `.env` 单管理员**(`ADMIN_USERNAME/PASSWORD`)可登录,无注册/找回;成功 `role=owner`。
+- **自动登录**:默认记住 token(EncryptedPrefs),重启免登;仅 `401/auth_expired` 才清 token 回登录。
+- **报错**:**已建立连接**(服务端有响应)时用服务端 `{error.message}`;**本地错误**(连不上/证书/DNS/超时)**由客户端本地文案负责**——不把本机错误误当服务端文案。
+- **密码默认隐藏**(可切换明文)。
+- **数据流**:`AuthManager.login(baseUrl,username,password)` → `POST {base}/api/v1/auth/login` → `{token,expiresAt,role}` → `EncryptedPrefs` 存(baseUrl+token+role+expiresAt)→ `HomeScreen`。
+- **状态机**:`Idle→Loading→Success(Home)|Error(msg)`(`error.from==="server"` 用后端 `message`,`"local"` 用本地文案);登录中禁用按钮防连点;登录失败不清已存 token。
+
+### S1.2 会话列表(已定稿 2026-09-08)
+- **主结构 = A**:启动**直接进主会话「与昔涟」(`web`) 聊天**;**顶栏汉堡 → 侧边栏**含【会话列表(切主/次级)+ IoT + App 设置 + 登出】。
+- **列表显示**:主会话="与昔涟",次级="会话 2/N"(按序号,title **自动**,零后端改动);无预览/时间(后端 `/sessions` 只有 id)。
+- **新建**:侧栏"+"新建次级(`POST /sessions` → `sessionId` → 切进去)。
+- **删除**:次级长按/滑出 → 确认 `Dialog` → `DELETE /sessions/{id}`;主会话禁删(提示"主会话不可删")。
+- **切换**:侧栏会话列表点选 → 切聊天页。
+- **数据流**:`GET /api/v1/sessions`(主会话 `web` 恒首位)。**依赖**:S1.1。**验收**:启动即主会话聊天;侧栏可切/建/删次级;主会话不可删;重启记住上次会话(可后续)。
+
+### S1.3 聊天页:历史加载(已定稿 2026-09-08)
+- **数据流**:`GET /sessions/{id}/messages?limit=50&before=&platform=` **倒序**返回,客户端**逆序渲染**(最新在底)。**`platform` 不传 = 看全量主会话**(a,能同步 web/kook/微信所有主会话消息)。
+- **分页**:初始 50 条;**上拉**加载更多用 `before=<最早时间>` 再往前翻(**50 步进**),追加顶部。
+- **消息来源标识**:每条消息**默认显示来源**(`platform`/`deviceId` 小字角标,如"来自 web" / "来自 kook" / "来自微信" / "来自安卓(voiceassist)")——主人既定诉求。
+- **时间**:**完全隐藏**(不显示时间戳/分组,对话沉浸)。
+- **空态**:主会话无消息 → 昔涟欢迎语。
+- **依赖**:S1.2。**验收**:进会话拉 50 条倒序展示;上拉翻 50/页;每条标来源;无时间戳。
+
+### S1.4 聊天页:发消息 + 流式(已定稿 2026-09-08)
+- **发消息**:WS `/ws/chat` 上行 `chat {sessionId,text,deviceId}`(**stream 流式**);本地乐观追加自己消息。
+- **流式渲染**:下行 `message.start` 开气泡 → `message.delta` **逐段追加**(气泡尾+光标)→ `message.end` 收口。
+- **流式期间新消息 = 两种模式(用户可切,默认「追加队列」)**:
+ ① **追加队列**(新消息进队列,流式结束后依次处理——豆包式);
+ ② **打断**(新消息触发 `interrupt`,保留残缺上下文 + 显示"打断"提示)。
+- **思维链 `thinking` / 工具卡 `tool_call`**:**默认展开**(方便观察思维;可折叠 + "总是收起"开关)。
+- **旁白 `/`**:灰斜体小字,**按出现位置依附气泡上/下**。
+- **`mood`**:显示情绪标签(可关)。
+- **渲染**:`` 拆气泡;图片;忙态 `session_busy/pending_*` 提示;`409/202` 兜底。
+
+### S1.5 侧边栏(汉堡抽屉)
+- **做什么**:顶栏左上汉堡 → `Drawer` 侧边栏:当前会话/聊天、**[预留] IoT 入口**、**[预留] App 设置入口**、登出。
+- **数据流**:登出 → 清 token + 断开 WS → 回登录。
+- **验收**:汉堡展开侧栏;IoT/设置点开显示"规划中"(Miuix 空态);登出回登录页。
+- **边界**:侧栏入口预留(P0 占位不真做)。
+
+### S1.6 401 / token 过期
+- **做什么**:REST 401 → `auth_expired` 清 token 停 WS → 回登录;WS 握手失败同理。
+- **验收**:JWT 过期或轮换后,下条请求自动登出重登,不无限 reconnect。
+
+## 阶段 2 · P1 语音助手(后端 ASR/TTS,依赖本会话 TTS/ASR 进程接通后端)
+### S2.1 系统默认语音助手
+- **做什么**:Manifest 声明 `VOICE_INTERACTION_SERVICE`/`AssistantService`(`android:role="Assist"`),用户在系统「语音助手/电源键」设本 App 默认 → 长按电源键由系统拉起本 App `AssistantActivity`(全屏)。
+- **依赖**:系统设置入口(MIUI 自家更顺);Android 版本/厂商适配。
+- **验收**:长按电源键 → 本 App 全屏助手打开(无悬浮球)。
+
+### S2.2 全屏助手 UI(AssistantActivity)
+- **做什么**:聆听动画 → 已识别文本(ASR 结果)→ 昔涟回复(流式)→ 重听/TTS;按钮:结束/放弃/再次说话。全程单页全屏,Miuix 玻璃质感。
+- **验收**:说话 → 文本上屏 → 昔涟回复 + 朗读。
+
+### S2.3 语音闭环(后端 ASR/TTS)
+- **链路**:录音(`MediaRecorder`/`AudioRecord`)→ **REST 上传到后端**(音频→ASR)→ 文本 → `POST /api/v1/chat {text,deviceId}`(或 WS)→ 昔涟回复 → **后端 TTS** 返回音频 → 本端播放。`voice_output`(WS)也可拿音频。
+- **依赖**:**后端 ASR/TTS 真接通**(本会话 TTS/ASR 进程:真实 STT/TTS provider + REST 语音端点绕 4KB + `voice_input.deviceId`)。
+- **验收**:对话 3 轮语音一问一答;IM 渠道(微信/KOOK)语音也走同一后端 ASR/TTS。
+
+## 阶段 3 · P2 智能
+- **IoT**:设备/场景列表(Miuix;预留接口 `GET /api/v1/iot/*`)。
+- **App 设置**:主题(Miuix 深/浅)、通知开关、语音偏好(ASR/TTS 后端已定)、关于。
+- **主动推送**:后端 proactive/backthink → 系统通知;前台服务保活(可选)。
+- **多角色**:`GET /personas` 只读展示;切换人格。
+- **记忆/子代理/自主思考**:只读面板(`memory`/`subagent`/`backthink` 过滤器)。
+
+---
+
+## 跨模块约定
+- **deviceId**:`voiceassist:myapp:v1`(design/16 §4.5),所有 `chat`/`voice_input` 携带 → 落库标来源、跨端可辨。
+- **主会话同步**:Android 加入 `web` 主会话,与 Web/IM 共享上下文;渲染按 `platform` 区分"本端/他端"(design/16 §4.4)。
+- **Miuix 映射**:`TopAppBar`(汉堡/标题)、`Scaffold`+`Drawer`(侧栏)、气泡(自定义 `BubbleView` 或 Miuix 卡片+毛玻璃)、`TextField`、`Button`(发送/语音)、`Snackbar`/`Dialog`(确认/错误)。**禁原生 Material**。
+- **安全红线**:token 加密存储、401 即登出、权限最小化、无悬浮球、不打日志敏感。
diff --git a/devdocs/02-前后端协同事项.md b/devdocs/02-前后端协同事项.md
new file mode 100644
index 0000000..f58067e
--- /dev/null
+++ b/devdocs/02-前后端协同事项.md
@@ -0,0 +1,207 @@
+# 02 — 前后端协同事项(Cyrene-For-Android ⇄ Cyrene v2)
+
+> **用途**:Android 端「要做/想做」,但**后端当前不支持、或契约未明确**的事项统一登记在这里。主人(AskaEth)按此去后端做适配与开发。
+> **状态机**:`待确认` → `已确认` → `已排期` → `已完成` / `不采纳`
+> **依据**:`Cyrene_v2/Docs/api-reference.md`、`Docs/api-client.md`、`DevDocs/design/12,14,15,16,21`(调研中发现多处文档与实现不一致,见 §E2)
+> **最后更新**:2026-09-08
+
+---
+
+## 登记规范(开发过程中持续维护)
+
+**触发条件**——开发 / 联调中遇到以下任一情况,**当场**追加到本文并立即向主人反馈,不攒到最后:
+
+1. 调用后端**直接失败**(501 / 404 / 500 / 字段缺失导致功能做不了);
+2. **契约或设计文档写了、但实现没做**(例如 `mode` 不落库、`voice_input` 不落库)——即「理论上应该支持但没有」;
+3. 后端行为与文档不符,导致客户端必须绕行。
+
+**每条要写清**:
+
+- **现象**:可复现的请求 / 响应或代码位置(`文件:行`);
+- **客户端为什么要**:不补会怎样;
+- **后端建议方案**:客户端期望的接口 / 字段 / 语义;
+- **当前绕行**:客户端现在怎么扛过去(没有就写「无,功能阻塞」);
+- **优先级**:阻塞 P0 / 影响体验 / P1 / P2 / 文档治理。
+
+**流程**:`待确认` → 主人确认 → `已排期` → 后端完成 → `已完成`(注明后端版本 / commit);不做的标 `不采纳` 并写明理由。
+
+**反馈节奏**:每轮开发结束时,在对话里列「本轮新增协同事项」(编号 + 一句话),主人直接照着修后端。
+
+---
+
+## A. 阻塞 P0 的(不解决就得改客户端设计)
+
+### A1. 结构化流帧不带 `sessionId` → 多会话流式无法归属
+- **现象**:`message.start/delta/end`、`thinking.*`、`tool_call.*`、`tool_execution.update` 的 payload 只有 `{index}` / `{id,index,delta}`,**没有 `sessionId`**(`model.StreamEvent` 无会话上下文)。
+- **客户端为什么要**:WS 是全局一条连接,多会话同时流式时无法判断 delta 属于哪个会话,切会话会串台。
+- **后端建议**:这些帧补 `sessionId`,或至少补 `turnId`(客户端用 turnId 反查会话)。
+- **当前绕行**:客户端硬约束「同一时刻只有一个会话在流式」(已拍板接受)。
+- **优先级**:中高(有绕行,不阻塞 P0)
+
+### A2. `interrupt` 清空 pending → 「打断」模式连发丢消息
+- **现象**:`interrupt {sessionId}` 取消在跑 turn **并清空 pending**,下行 `session_interrupted{dropped}`。
+- **客户端为什么要**:S1.4 定了「追加队列 / 打断」两种模式;用户选「打断」时连发多条会被静默丢弃。
+- **后端建议**:`interrupt` 增加 `scope`(`current` = 只取消当前 turn / `all` = 连 pending 一起清),默认维持现状。
+- **当前绕行**:打断模式下 UI 限制一次只能发一条 + 明确提示。
+- **优先级**:中
+
+### A3. 消息 `mode` 不落库 → 渲染形态丢失
+- **现象**:design/16 §5.1 写「消息体带 `mode`」,但 Web 路径(`session_persist.go`)与 IM 路径(`channelbridge/persist.go`)**都不写 `Mode`**,字段 `omitempty` → 实际恒缺。
+- **客户端为什么要**:9 值渲染 mode(chat/code/home/work/docs/voice/vision/agents/settings)决定气泡/卡片形态,且「只有 chat 才解析 `
`」这条规则依赖它。
+- **后端建议**:持久化时写入 `mode`(design/16 已约定,属实现缺失)。
+- **当前绕行**:客户端一律回落 `chat`。
+- **优先级**:中
+
+### A4. 会话 `title` 恒空 → 列表没名字
+- **现象**:`historySessionSummary.title` 有字段,但 `store.go` 恒写 `""`,无生成规则/接口。
+- **后端建议**:后端生成(截首条用户消息),或提供 `PATCH /sessions/{id}` 让客户端改名。
+- **当前绕行**:客户端本地生成「会话 N」+ 序号持久化。
+- **优先级**:低
+
+### A5. WS 断线无补发 / 无 resume cursor
+- **现象**:无 `since` / `lastEventId`;`seq` 只用于排序,未规定可续传;重连仅有 `history` 回放主会话最近 50 条。
+- **客户端为什么要**:断线期间超过 50 条就永久丢失,只能整页重拉。
+- **后端建议**:`/ws/chat?since=` 补发,或提供 `GET /sessions/{id}/messages?after=` 正序增量接口。
+- **当前绕行**:重连后 REST 按 `before` 补差 + 按 `id` 去重。
+- **优先级**:中
+
+---
+
+## B. 影响体验 / 客户端需绕行
+
+### B1. 无图片上传端点 → 大图只能 base64 塞进 `chat.image`
+- **现象**:无 `/upload`;`image` 只接受 base64 data URI 或 http(s) URL;WS 上行帧限 **4096 字节**。
+- **客户端为什么要**:主人已定「发图先做着」,但没有上传端点就只能把压缩后的 base64 走 REST,大图会撑大请求体(WS 完全走不通)。
+- **后端建议**:`POST /api/v1/upload`(multipart)→ 返回 URL;`chat.image` 收 URL。
+- **当前绕行**:客户端压缩后再 base64,限制单图尺寸。
+- **优先级**:中高(主人已要求做发图)
+
+### B2. token 24h 且无 refresh 端点
+- **现象**:`tokenTTL=24h`,无 refresh/renew;`rotate-jwt` 让全部 token 立即失效。
+- **客户端为什么要**:主人选了「静默重登」,但静默重登需要长期保存账号密码。
+- **后端建议**:提供 refresh token,或拉长 TTL + 支持「记住登录」的长期凭据。
+- **当前绕行**:存密码做静默重登(安全上不理想)。
+- **优先级**:中高
+
+### B3. `GET /sessions` 信息太少(无未读、无最后一条预览)
+- **现象**:只有 `{id,type,title?,createdAt}`。
+- **客户端为什么要**:会话列表做不了预览和未读红点;多端同步时无法标记已读。
+- **后端建议**:补 `lastMessage{content,role,createdAt}`、`unreadCount`,或提供已读游标接口。
+- **优先级**:低(P0 按序号展示即可)
+
+### B4. `platform` / `mood` / `mode` 取值未穷举
+- **现象**:文档只给示例(`web`/`telegram`/`weixin-ilink`/`qq-obv11`/`kook`;`calm`;`normal`)。
+- **客户端为什么要**:来源角标和情绪标签需要**稳定映射**(显示「来自微信」而不是原始字符串)。
+- **后端建议**:给出权威枚举表 + 未知值回落约定。
+- **当前绕行**:客户端维护映射表 + 未知值原样显示。
+- **优先级**:低
+
+### B5. 管理员端点 5 个返回 501
+- **清单**:`POST /admin/users`、`POST /admin/iot/scenarios`、`POST /admin/iot/rules`、`POST /admin/plugins/install`、`POST /admin/rag/delete`。
+- **客户端为什么要**:管理员页面要决定这些入口做不做、做了怎么降级。
+- **当前绕行**:客户端硬编码「已知未实现」清单,入口置灰。
+- **优先级**:低
+
+### B6. `proactive_message` 只在 `api-reference.md` 有,`api-client.md` 摘要缺失
+- **后端建议**:补进 `Docs/api-client.md`。
+- **优先级**:低(文档治理)
+
+---
+
+## C. P1 语音相关
+
+### C1. `voice_input` 轮次不落库、不过编排器
+- **现象**:`runVoiceTurn` 直调 `runner.Run`,无 `history.AppendFull`、不经 `session_orchestrator` → **无 `message_persisted`、无 busy/pending、不跨端同步**;与 `api-client.md` §3.1「随语音轮次落库」不符。
+- **客户端为什么要**:语音问过的事必须进历史、跨端可见,否则在 Web 上查不到。
+- **后端建议**:把语音轮次接入编排器与持久化(与文本轮次同一路径),或在文档里明确「语音不留痕」。
+- **当前绕行(客户端已定)**:**不用 `voice_input` 提交对话**,改为 `REST /audio/transcribe` 转写 → **WS `chat`**(落库 + 流式 + 编排)→ `REST /audio/speech` 播放。
+- **优先级**:中
+
+### C2. 语音降级到 silent 时客户端无法感知
+- **现状**:cloud 档已接阿里百炼(STT `qwen-audio-3.0-asr-flash` / TTS `cosyvoice-v3-flash`,需 `DASHSCOPE_API_KEY`);`edge-tts`/`espeak` 未实现;`local` 默认不注入;`silent` 兜底返回空音频。
+- **客户端为什么要**:没有 key 时语音会「静默成功但没声音」,客户端无法提示。
+- **后端建议**:降级到 silent 时给出可识别信号(响应头 `X-Voice-Provider: silent` 或专门错误码)。
+- **优先级**:中
+
+### C3. `voice_output` 是否带 `sessionId` / `deviceId` 未规定
+- **客户端为什么要**:多端同时说话时无法区分归属。
+- **优先级**:低
+
+---
+
+## D. P2 / 路线缺失(客户端想做,后端尚无能力)
+
+### D1. 移动端推送通道(系统通知)—— 最大缺口
+- **现象**:主动推送只有 WS `proactive_message`(仅 owner/admin 连接收到),**App 不在前台就收不到**。
+- **客户端为什么要**:design/21 §5 的 P2 明确要「主动推送 → 系统通知」,目前没有任何移动推送通道。
+- **后端建议**:接 FCM / 个推 / 自建长连接保活,并在 WS 之外提供推送投递。
+- **优先级**:P2,但属**路线级缺失**,建议早登记。
+
+### D2. 消息搜索 / 会话搜索
+- **现象**:无搜索接口(`/admin/memories/search` 是记忆,不是消息)。
+- **优先级**:P2
+
+### D3. 消息编辑 / 删除 / 重发
+- **现象**:无接口。
+- **优先级**:P2
+
+### D4. 未读状态 / 已读游标
+- 见 B3。
+- **优先级**:P2
+
+### D5. 会话改名 / 置顶 / 排序
+- 见 A4;`GET /sessions` 次级按 `created_at ASC`,改名/置顶需要新接口。
+- **优先级**:P2
+
+---
+
+## E. 文档与契约治理
+
+### E1. `X-API-Version` 形同虚设
+- **现象**:服务端不校验该头;CORS `Allow-Headers` 只有 `Authorization, Content-Type`(未放开 `X-API-Version`)。
+- **建议**:要么真正校验,要么从契约里去掉,避免客户端误以为存在版本协商。
+
+### E2. 文档与实现不一致(调研发现)
+| 事项 | 文档说法 | 实现 |
+|---|---|---|
+| 真实 ASR/TTS | design/21 §4「未接通」 | 已接百炼并实测 |
+| `voice_input.deviceId` | design/21「待补」 | 已有 |
+| REST 语音端点 | design/21「待新增」 | 已有 |
+| 语音轮次落库 | api-client「随轮次落库」 | 未落库(见 C1) |
+| 消息 `mode` | design/16 §5.1「消息体带 mode」 | 不落库(见 A3) |
+| 来源标识注入 | design/16 §4.6.1「文本前缀」 | 独立 system 消息 + 结构化 `SenderName` |
+- **建议**:以 `api-reference.md` + 代码为准,`api-client.md` 随实现同步(AGENTS.md 已有此红线)。
+
+### E3. `thinking` 可达性确认
+- **现状**:WS 实时/回放已剥离,只有 `GET /sessions/{id}/messages`(owner/admin)返回。
+- **客户端影响**:历史消息的思维链需要额外请求。
+- **待确认**:这是有意为之(隐私)还是可优化?
+
+---
+
+## 附:状态一览
+
+| 编号 | 事项 | 状态 | 优先级 |
+|---|---|---|---|
+| A1 | 流帧缺 `sessionId` | 待确认 | 中高 |
+| A2 | `interrupt` 清 pending | 待确认 | 中 |
+| A3 | `mode` 不落库 | 待确认 | 中 |
+| A4 | `title` 恒空 | 待确认 | 低 |
+| A5 | 无 resume cursor | 待确认 | 中 |
+| B1 | 无图片上传端点 | 待确认 | 中高 |
+| B2 | 无 token refresh | 待确认 | 中高 |
+| B3 | `sessions` 信息少 | 待确认 | 低 |
+| B4 | 枚举未穷举 | 待确认 | 低 |
+| B5 | admin 501 清单 | 待确认 | 低 |
+| B6 | `proactive_message` 文档缺失 | 待确认 | 低 |
+| C1 | 语音轮次不落库 | 待确认 | 中 |
+| C2 | 语音降级无信号 | 待确认 | 中 |
+| C3 | `voice_output` 字段 | 待确认 | 低 |
+| D1 | 无移动推送通道 | 待确认 | P2(路线缺失) |
+| D2 | 无消息搜索 | 待确认 | P2 |
+| D3 | 无编辑/删除/重发 | 待确认 | P2 |
+| D4 | 无未读状态 | 待确认 | P2 |
+| D5 | 会话改名/置顶 | 待确认 | P2 |
+| E1 | `X-API-Version` 虚设 | 待确认 | 低 |
+| E2 | 文档与实现不一致 | 待确认 | 中 |
+| E3 | `thinking` 可达性 | 待确认 | 低 |
diff --git a/devdocs/03-客户端细化开发方案.md b/devdocs/03-客户端细化开发方案.md
new file mode 100644
index 0000000..31d8ee3
--- /dev/null
+++ b/devdocs/03-客户端细化开发方案.md
@@ -0,0 +1,197 @@
+# 03 — 客户端细化开发方案(Cyrene-For-Android)
+
+> 配套:`DESIGN.md`(总览)、`01-功能拆解与分步路线.md`(分步路线)、`02-前后端协同事项.md`(后端缺口登记)
+> 本文是**决策固化 + 可施工的细化方案**,随讨论更新。
+> 最后更新:2026-09-08
+
+---
+
+## 0. 已定决策(截至 2026-09-08)
+
+| # | 决策 | 备注 |
+|---|---|---|
+| D1 | 主界面 = 常规 AI APP(会话列表 + 聊天流) | |
+| D2 | 长按电源键 = 系统默认语音助手(`VOICE_INTERACTION_SERVICE`),全屏、无悬浮球 | **需先验 ROM 能否把本 App 设为默认助手** |
+| D3 | UI 严格 Miuix;**气泡自研、侧边栏自研** | Miuix 无 Drawer / 气泡组件 |
+| D4 | **minSdk 33** | 解锁 `miuix-blur`(硬下限 33) |
+| D5 | 先纯文本 P0,语音 P1 | |
+| D6 | 顶栏汉堡 → 侧边栏;含 IoT、App 设置、管理入口 | |
+| D7 | 服务器地址可配;单管理员登录;自动登录;401 登出 | |
+| D8 | 会话列表 = **本地回退快照 + 服务端良好时自动刷新** | §3.1 |
+| D9 | 消息历史缓存 = **方案 B**(每会话最近 50 条快照,LRU 5 个会话) | §3.2 |
+| D10 | 发图先做(REST `chat.image`,客户端压缩) | 后端无上传端点 → `02/B1` |
+| D11 | 历史思维链做(按需 REST `/messages`) | |
+| D12 | 静默重登 | 需存密码;后端无 refresh → `02/B2` |
+| D13 | 打断语义待后端适配 | `02/A2` |
+| D14 | 接受「同一时刻只有激活会话在流式」 | 流帧缺 `sessionId` → `02/A1` |
+| D15 | 管理功能独立管理员页,**不进聊天界面** | 渠道配置等归 Web 后台 |
+| D16 | 管理员页只做**只读监控**(概览 / 后台思考 / 记忆 / 人格) | 其余全部归 Web 后台 |
+| D17 | 会话标题本地生成「会话 N」+ 序号持久化 | 后端 `title` 恒空 → `02/A4` |
+| D18 | 会话列表回退时**显示上次同步时间** | §3.1 |
+| D19 | 配色用 **Miuix 默认配色**(`ColorSchemeMode.System`,跟随系统深浅色) | 不用 Monet 动态取色 |
+
+---
+
+## 1. 工程骨架
+
+- **版本钉死**(Miuix 0.9.3 牵引):Kotlin 2.4.0 / Compose Multiplatform 1.11.1 / AGP 9.2.1 / JDK 21。Miuix 以 `api` 暴露 `compose.foundation`,版本会被它牵制,不要自行升级。
+- **单 module `app`**,包结构 `com.askaeth.cyrene.{app,core,data,domain,platform,ui}`;包内分层,为 P2 拆 `:core:ui` / `:feature:chat` 留边界。
+- **DI:手写 `AppContainer`**(不引 Hilt;`miuix-nav` 与 Hilt 有已知冲突 issue #407)。
+- **minSdk 33 / compileSdk & targetSdk 跟 Miuix(37)**。
+- **网络**:Retrofit + OkHttp + kotlinx-serialization。
+ - `X-API-Version: 1` 照发(服务端不校验,CORS 也未放开该头 → `02/E1`)。
+ - debug 用 `networkSecurityConfig` 放行明文 HTTP;自部署支持**用户显式信任单个自签服务器**,绝不全局信任。
+ - 超时:连接 10s / 读 60s(LLM 慢);WS `pingInterval(30s)`。
+ - `HttpLoggingInterceptor` 仅 debug,**脱敏 `Authorization` 与 image base64**。
+- **R8**:`@Serializable` 类与 Retrofit 泛型签名保留规则。
+- **`BASE_URL` 只是默认值**,真实服务器地址运行时用户可改(D7)。
+- **主题**:`MiuixTheme(controller)`,默认 `MonetSystem`;设置页提供 跟随系统 / 浅色 / 深色 / 取色。
+
+---
+
+## 2. 数据层与契约封装
+
+- **DTO 不用 enum**:`mode` / `platform` / `mood` / `role` / `stopReason` / `error.code` 后端全部「未穷举」。用 `String` + 客户端映射表,未知值走默认(`mode` → `chat`)。`ignoreUnknownKeys = true`,可选字段全 nullable。
+- **统一错误模型**:`Server(code, message, httpStatus)` vs `Local(kind)`(无网 / DNS / TLS / 超时 / 解析)。S1.1 已定「已建连接用服务端文案,本地错误用本地文案」;401 单独一类,触发全局登出。
+- **分页与去重**:WS 首帧 `history` 与 REST 首屏**必然重叠**,且文档未规定 `history` 排序。规则:以 `id` 为唯一键 upsert;本地按 `createdAt` 排序(同毫秒用 `id` 兜底);`history` 到达时只 merge 不重置;次级会话无回放,一律走 REST。
+- **乐观消息合并**:上行 `chat` **无 clientMsgId**,`reqId` 服务端不回填 → 靠「同 role + 时间窗 + 文本匹配」把本地临时气泡归一为后端 `id`;匹配不上则保留两条(宁重复不丢)。
+- **deviceId**:首次生成后持久化,形如 `voiceassist:cyrene:v1`,所有 `chat` / `voice_input` 携带。
+- **token**:存 `token` / `expiresAt` / `role` / `baseUrl` 到 EncryptedSharedPreferences;24h 过期、无 refresh(`02/B2`),采用静默重登(D12);运行中 401 → 清 token、断 WS、回登录。
+- **契约漂移防护**:维护 `CONTRACT.md` 记录「客户端实际依赖的字段与行为 + 对应后端版本」;关键 JSON 用 MockWebServer 写契约测试。
+
+---
+
+## 3. 本地持久化
+
+### 3.1 会话列表回退快照(D8)
+
+**目标**:网络波动时列表不白屏;服务端可用时以服务端为准。
+
+- **快照内容**:`List`(`id` / `type` / `title` / `createdAt`)+ `fetchedAt` + **指纹**(`baseUrl` + `username`)。
+- **存储**:app 私有目录单文件 `sessions.json`(或 DataStore)。
+- **启动流程**:读快照 → 立即渲染 → 并发 `GET /sessions` → 成功后整体替换并回写快照。
+- **失败流程**:保留快照,列表顶部显示轻量条「网络异常,显示上次结果 · 同步于 <相对时间>」+ 重试。相对时间由本地 `fetchedAt` 算(刚刚 / N 分钟前 / N 小时前 / 日期),不依赖服务端时间。
+- **合并策略**:**服务端结果整体替换,不做 merge**(避免已删除会话变幽灵)。服务端请求失败时**不清空快照**。
+- **指纹不匹配**(换了服务器地址 / 账号)→ 丢弃快照。
+- **排序**:主会话恒首位,次级按 `createdAt ASC`(与后端一致)。
+- **当前会话 id** 一并持久化,冷启动直接进上次会话。
+- **序号映射** `sessionId → N` 独立持久化:新建时取「当前最大序号 + 1」,删除会话时同步删映射。
+- **写入时机**:每次成功拉取后;新建 / 删除后以服务端为准回写。
+
+### 3.2 消息历史快照 —— 方案 B(D9)
+
+- **粒度**:每会话一个文件,文件名用 `sessionId` 的 hash(`web:` 不能直接做文件名)。
+- **内容**:最近 50 条 `historyMessage`(`id`/`role`/`content`/`mode?`/`mood?`/`image?`/`platform?`/`deviceId?`/`senderName?`/`createdAt`)。**不含 `thinking`**(WS 与列表接口都拿不到)。
+- **LRU**:最多保留 **5 个会话** 的文件,按最后访问时间淘汰。
+- **读取**:进会话 → 立即渲染快照 → 并发拉 REST 最新 50 条 → 按 `id` 合并(服务端优先)→ 回写。
+- **写入**:收到 `message_persisted` / 分页结果后**节流写**(500ms debounce),避免流式期间频繁 IO;流式中的临时气泡不落盘,只落 `message_persisted` 的最终态。
+- **大小上限**:单文件 2MB,超出只保留最近 20 条。
+- **图片字段**:**base64 data URI 不落盘**(体积不可控),只保留 http(s) URL;内存中可正常显示。
+- **清除时机**:登出、切换服务器地址、指纹不匹配时清空全部缓存。
+- **隐私**:文件放 app 私有目录 + `android:allowBackup="false"`(或 backup rules 排除)。若要更强,用与 EncryptedSharedPreferences 同族的 `EncryptedFile`。
+
+### 3.3 其他 DataStore 项
+
+| 数据 | 用途 |
+|---|---|
+| `deviceId` | 跨会话稳定标识 |
+| 会话序号映射 | D17 |
+| 用户偏好 | 主题、旁白开关、mood 显示、思维链默认展开、打断模式、通知开关 |
+| 每会话草稿 | 防进程被杀丢输入 |
+| 上次会话 id | 冷启动直达 |
+
+### 3.4 不做的事
+
+- **离线待发队列**:P0 不做,发送失败标红重试、内容留在输入框。
+- **WS `seq` 游标持久化**:后端无续传机制,存了没用。
+
+---
+
+## 4. WS 实时通道
+
+- **连接状态机**:`Idle → Connecting → Connected → Reconnecting(退避 1/2/4…30s + 抖动) → Offline(需登录)`。握手 401 不再重试,直接登出。
+- **重连补全**:无 resume cursor(`02/A5`)→ 重连后主会话用新连接的 `history` + REST 补差(`before = 本地最后一条 createdAt`),次级会话走 REST,全程按 `id` 去重。
+- **事件路由**:带 `sessionId` 的按会话分发;**不带 `sessionId` 的**(`message.*` / `thinking.*` / `tool_call.*`)归属「当前 active turn」→ 因此**硬约束同一时刻只有一个会话在流式**(D14)。切会话时若正在流式:等结束或 `interrupt`。
+- **流式段落状态机**:`index` 编号规则文档未定义 → 按首次出现顺序建段,`start` 建 / `delta` 追加 / `end` 收口;`stream_end` 才算整轮结束。要容忍丢帧(服务端发送缓冲 32,满则丢)。
+- **忙碌/队列 UI 映射**:`session_busy{mode}` → 轻状态条;`pending_ack{queueDepth}` → 「已排队 N 条」;`pending_full{max}` → 队列满(默认 5);`session_interrupted{dropped}` → 「已打断,丢弃 N 条」。注意编排 `mode`(`idle/thinking/executing/subagent`)与消息渲染 `mode` 是两个命名空间。
+- **打断模式**:后端 `interrupt` 会清空 pending(`02/A2`)→ 该模式下 UI 限制一次只发一条并明确提示。
+
+---
+
+## 5. 渲染管线
+
+- **解析器**:纯函数、可单测,`parse(raw): List`。权威规则在 `configs/personas/cyrene.yaml`。
+ - `…
` 每段一个气泡;无 `` 时按空行 `\n\n` 回退切分,单个 `\n` 不切。
+ - `` / `` → 灰斜体旁白,依附气泡:首部连续块在上、尾部连续块在下、中间块上提到 head。
+ - 兼容漂移:全角 `*…*`(短于 40 字符且无换行 → action);半角 `*…*` **仅当以 动作/旁白/内心/神态/轻声 开头**才算旁白,否则保持普通文本。
+- **流式增量解析**:delta 会把标签切半(``)→ 带缓冲的增量解析器,保留尾部未完成片段,只在能确定边界时产出;未闭合 `` 不显示直到闭合。行为对齐 Web 端 `parseContent.ts` + `chat.tsx`。
+- **mode 回落**:后端不落库(`02/A3`)→ P0 全部按 `chat` 渲染。
+- **thinking / tool_call**:仅 WS 实时有;历史思维链按需请求 REST `/messages`(D11)。工具卡把 `tool_call.delta`(参数 JSON)与 `tool_execution.update`(输出)分两块渲染。
+- **图片**:Coil 加载;base64 先读尺寸再决定是否解码,避免 OOM。
+- **Markdown**:P0 不渲染,代码只给等宽字体 + 横向滚动。
+
+---
+
+## 6. UI / Miuix 落地
+
+| 需求 | 方案 |
+|---|---|
+| 顶栏 + 汉堡 | `SmallTopAppBar(navigationIcon = { IconButton { Icon(MiuixIcons.Sidebar) } })` |
+| 侧边栏 | **自研 `MiuixDrawer`**:`AnchoredDraggableState` + scrim + `Modifier.offset` lambda 版;汉堡开、抽屉内拖拽关;**不做左边缘滑出**(与系统返回手势冲突);`BackHandler` 关抽屉 |
+| 气泡 | **自研**:`Surface` / `Card` + `RoundedCornerShape`,最大宽度 `min(0.78 × 屏宽, 600dp)` |
+| 列表 | `LazyColumn`(Foundation)+ `overScrollVertical()` + `VerticalScrollBar`,`key = id` |
+| 输入 / 按钮 / 设置 / 弹窗 / 提示 / 加载 | `TextField` / `IconButton`+`FAB` / 全套 `*Preference` / `OverlayDialog`(须在 `Scaffold` 内)/ `Snackbar`+Host / `ProgressIndicator` |
+| IME | Miuix `Scaffold` 默认只处理 `systemBars ∪ displayCutout`,需自行 `imePadding()` |
+| 毛玻璃 | 只用在顶栏 / 底栏,不大面积铺;<33 已在 D4 排除 |
+
+**侧边栏信息架构**
+
+```
+├─ 会话(主 / 次级)
+├─ IoT(预留)
+├─ App 设置
+├─ 管理(owner 可见)→ 独立导航图 + 独立 Scaffold,只读监控
+│ ├─ 概览(metrics / trace)
+│ ├─ 后台思考
+│ ├─ 记忆
+│ └─ 人格
+└─ 关于 / 登出
+```
+
+- **管理员页范围**(D15/D16):只做**只读监控**;渠道配置、LLM、RAG、插件、用户、自动化、系统危险操作全部归 **Web 后台**。危险操作即使将来做也必须 `OverlayDialog` 二次确认。
+- P0 管理员页只放入口占位(显示「规划中」),P2 再实现。
+
+---
+
+## 7. P1 语音(依赖后端)
+
+- **第一件事是 spike,不是写 UI**:`VOICE_INTERACTION_SERVICE` 只保证系统「助手入口」能指向本 App;**MIUI 是否允许把第三方设为默认助手、长按电源键能否唤起**必须真机验证(不写代码,10 分钟)。验不过就换触发:系统助手手势 / 桌面快捷方式 / 通知磁贴 / 蓝牙耳机键 / App 内按钮。
+- **链路(已按后端现状修正)**:`AudioRecord` 录音 → WAV → `POST /audio/transcribe`(REST,绕 4KB)→ 文本 → **WS `chat`**(落库 + 流式 + 编排)→ 回复 → `POST /audio/speech`(**返回裸音频字节,非 `{data}` 信封**)→ `AudioTrack`/ExoPlayer 播放,支持打断。
+- **不用 `voice_input` 提交对话**:该路径不落库、不过编排器、无 `message_persisted`(`02/C1`)。
+- 权限:`RECORD_AUDIO` 运行时申请;录音期间可选前台服务。
+
+---
+
+## 8. 里程碑与验收
+
+| 里程碑 | 内容 | 验收 |
+|---|---|---|
+| **M0 骨架** | Gradle + 依赖 + Miuix 主题 + 空壳首页 | `./gradlew :app:assembleDebug` 通过,App 起得来 |
+| **M1 登录** | 服务器地址可配 + 错误分类 + EncryptedPrefs + 自动登录 + 401 登出 | 错地址有本地文案、对地址能进;杀进程重开免登;token 失效自动回登录 |
+| **M2 只读主会话** | WS 连接 + `history` + REST 分页 + 倒序渲染 + 来源角标 + 会话列表回退 | 断网重开列表不白屏;进主会话看到历史;上拉翻页无重复;每条标来源 |
+| **M3 发送 + 流式** | `chat` 上行 + 乐观消息 + 流式气泡 + 忙碌/队列状态 + 失败重试 | 发消息立即上屏;流式逐字出;Web 端同步可见;忙时提示排队 |
+| **M4 富渲染** | `
` 拆气泡 + 旁白 + thinking/工具卡 + 图片 | 多气泡与旁白位置正确;流式半截标签不崩;图片正常显示 |
+| **M5 多会话 + 侧边栏** | 自研 Drawer + 新建/切换/删除 + 序号持久化 | 汉堡开合顺滑;次级可建可删;主会话禁删;重启记得上次会话 |
+| **M6 打磨** | 设置页、空态/错误态、无障碍、性能 | 字体放大不炸;冷启动 < 1.5s;无内存泄漏 |
+
+**P1**:语音 spike → 全屏助手 UI → 语音闭环。
+**P2**:管理员页只读监控、IoT、主动推送(依赖 `02/D1`)、多角色。
+
+---
+
+## 9. 待定项
+
+1. 消息历史快照是否改用 `EncryptedFile`(当前建议:私有目录 + 关备份,P0 够用)。
+2. 管理员页 P0 是否只占位(当前建议:是)。
+3. `CONTRACT.md` 是否现在就建(建议 M2 之前建)。
+4. 语音 spike 的具体验证清单与机器。
diff --git a/devdocs/DESIGN.md b/devdocs/DESIGN.md
new file mode 100644
index 0000000..ade590d
--- /dev/null
+++ b/devdocs/DESIGN.md
@@ -0,0 +1,95 @@
+# Cyrene-For-Android — Cyrene 安卓客户端设计文档(超详细讨论稿 v1)
+
+> 配套 Cyrene v2(git.yeij.top/AskaEth/Cyrene_v2)的 Android 客户端。
+> **主人已确认的关键决策**(2026-09-08):
+> - 主界面 = **常规 AI APP 风格**(会话列表 + 聊天流)。
+> - **长按电源键唤醒 = 全屏语音助手**(系统级语音助手交互),**不要悬浮球**。
+> - UI **严格遵守 MIUIx / Miuix 设计风格**:官网 https://compose-miuix-ui.github.io/miuix/zh_CN/ ,组件库 https://compose-miuix-ui.github.io/miuix/zh_CN/components/ 。
+> - **先做纯文本版本**(P0);语音(P1)后续。
+> - 顶栏左上角**汉堡菜单**展开**侧边栏**,**预留 IoT 入口 + App 设置页入口**。
+> - 后端契约详见 `Cyrene v2` 的 `Docs/api-reference.md` + `DevDocs/design/21-安卓app语音助手.md`(本文件为端侧细化)。
+> 命名:仓库作者 `AskaEth`,日常名 `YeiJi / 叶酱`;App 名 `Cyrene`,角色 `昔涟`。
+
+---
+
+## 0. 目录与工程形态
+- **单 Module**:`app`(Kotlin + Jetpack Compose),Gradle 版本目录。
+- 依赖:`compose-miuix-ui`(Miuix 组件)、`androidx.lifecycle.viewmodel-compose`、`Retrofit + OkHttp + kotlinx-serialization`、`okhttp`(WebSocket)、`EncryptedSharedPreferences`(JWT)、`hilt`(DI,可选)。
+- `BuildConfig`:`BASE_URL`(默认可 `http://:8080`,release 换 HTTPS)。
+- 包结构:`com.askaeth.cyrene.{app,core,data,domain,platform,ui}`(`app`=Activity/导航;`core`=Miuix 主题/Auth;`data`=Retrofit/WS/仓库;`domain`=会话/消息模型;`platform`=语音/权限/电源键;`ui`=各 Screen/组件)。
+
+## 1. 总体信息架构(功能分类)
+```
+Cyrene-For-Android
+├─ A. 聊天(主界面,AI APP 风格)
+│ ├─ 会话列表:主会话「与昔涟」(web) + 次级会话(web:),可新建/删除/恢复
+│ └─ 聊天页:气泡流(多气泡、/ 旁白灰斜体仅chat、思维链/工具卡折叠、图片)、输入框+发送、语音按钮(P0占位)
+├─ B. 侧边栏(汉堡抽屉)
+│ ├─ 会话/聊天
+│ ├─ [预留] IoT(设备/场景)入口
+│ ├─ [预留] App 设置入口
+│ ├─ [可选] 记忆 / 子代理 / 自主思考(只读)
+│ └─ 关于 / 登出
+├─ C. 语音助手(长按电源键唤醒,P1)— 全屏,无悬浮球
+│ ├─ 展开: 聆听动画 → 已识别文本 → 昔涟回复 → 再听/TTS
+│ └─ 系统级: AccessibilityService/系统语音助手回调 → 启动助手 Activity(全屏)
+├─ D. [预留] IoT:设备/场景列表(占位)
+└─ E. [预留] App 设置:主题(Miuix 深/浅)、通知、语音偏好(ASR/TTS 端侧 vs 后端)、关于
+```
+
+## 2. 页面路由(单 Activity + 导航)
+- `LoginScreen`(首启无 token)→ `HomeScreen`(会话列表,含顶栏汉堡)→ `ChatScreen({sessionId})`(聊天)
+- `AssistantActivity`(P1 全屏语音助手,长按电源键拉起;无悬浮球,就一个全屏页)
+- `IotScreen` / `SettingsScreen`(占位页,P0 展示"规划中")
+
+## 3. 数据层(对照现有契约,P0 纯文本即用)
+- **认证**:`POST /api/v1/auth/login {username,password}` → `{token,expiresAt,role}`(`role=owner`,单账号)。JWT `Authorization: Bearer`+`X-API-Version: 1`;`EncryptedSharedPreferences` 存 token;**REST 401 → `auth_expired` 清 token → 回登录**;WS 握手失败同理。
+- **会话**:`GET /api/v1/sessions`(主会话 `id="web"` 恒在);`POST /api/v1/sessions` 建次级;`GET /sessions/{id}/messages?limit=50&before=&platform=`(**倒序热分页**,`platform=` 过滤);`DELETE /sessions/{id}`(主会话删=403)。
+- **发消息**:`POST /api/v1/chat {sessionId,text,deviceId,stream?}`(`stream:true`→转 WS;忙时 `202 {queued,queueDepth}`、队满 `409 chat_busy`)。
+- **WS** `/ws/chat?token=`:上行 `chat`/`interrupt`;下行 `message.start|delta|end`、`thinking.*`、`tool_call.*`、`message_persisted`、`history`(握手后首帧主会话最近 50 条)、`mood`、`session_busy`/`pending_*`/`session_idle`、`voice_output`(P1)。`message_persisted`/`history` 载荷为 `historyMessage`。
+- **消息 DTO**:`{id,sessionId,role,content,mode?,mood?,image?,thinking?,platform?,deviceId?,senderName?,adapterId?,createdAt}`;`thinking` 仅 owner 可见;主流 `mode` 有 `chat`(含 ``/`/` 需端侧解析)。
+- **deviceId**:`voiceassist:myapp:v1`(design/16 §4.5 允许自定可读前缀),`POST /chat`/ws `chat` 携带 → 落库标注来源;主会话 `web` 与 Web 共享上下文(multiplatform 同步)。
+
+## 4. 消息渲染(聊天页核心)
+- 按 `mode` 决定形态:`chat` 用气泡;其它 `mode`(backthink/工具/审计等 refs)卡片化或折叠。
+- **`chat` 文本解析**:把 content 按 **``** 拆成多条气泡(每段一条);`…`/`…` 解析为**灰斜体小字旁白**(依附气泡,不独立成气泡);`*…*`/`*…*` 兼容。**非 chat 消息不解析**这些标签。
+- `thinking`(仅 owner):可折叠「思维链」面板;`tool_call` →「工具卡」(折叠,可看参数/结果)。
+- 流式:`message.delta` 追加到当前气泡末尾 + 光标;`message.end` 收口。
+- 图片:`image`(base64 data: URI 或 URL)→ Coil 加载。
+
+## 5. 语音助手(P1,长按电源键 全屏,无悬浮球)— 端侧与后端协同
+- **触发**:① 系统级语音助手回调(`VOICE_INTERACTION` / `AssistantService`)或 ② `AccessibilityService` 监听 `KEYCODE_POWER` **长按**(Android 对电源键长按有限制,需在系统「电源键双击/长按」设置里把本 App 设为语音助手,或通过系统辅助「voice assistant」入口)。落地:**声明 `VOICE_INTERACTION_SERVICE`**,长按电源键由系统拉起本 App 的 `AssistantActivity`(全屏)。
+- **链路**:`AssistantActivity` 聆听(`MediaRecorder`/`AudioRecord`)→ 录音交给 **ASR**(**端侧 or 后端**,见开放问题;后端当前 ASR/TTS 是占位,需先补 design/21 后端补强或端侧 whisper)→ 出文本 → `POST /api/v1/chat {text,deviceId}`(或 WS 流式)→ 昔涟回复 → **TTS** 合成朗读(端侧 e.g. `TextToSpeech` 或后端)。全程单页无悬浮球。
+- **保活/后台**:可选前台服务(语音持续/推送),权限最小化;无需悬浮球全局浮层。
+
+## 6. MIUIx / Miuix 映射(紧跟 compose-miuix-ui)
+- 用 `compose-miuix-ui` 的 `MiuixTheme` + 组件:`TopAppBar`(汉堡/标题)、`Scaffold`/`Drawer`(侧边栏)、气泡用 `BubbleView`/卡片、`TextField`(输入)、`Button`(发送)、`Switch`/`List`(设置)、`Snackbar`/`Dialog`。**禁用原生 Material 组件**,全 Miuix 风格(圆角、毛玻璃、液态玻璃、强调色)。
+- 顶栏左上汉堡 → 侧边栏(`Drawer`);侧边栏含「IoT」「App 设置」占位入口(P0 点开显示"规划中")。
+
+## 7. P0(纯文本)MVP 任务清单
+1. 工程搭建:Gradle + Compose + Miuix 依赖 + `BuildConfig.BASE_URL` + Hilt(可选)。
+2. `core`:Miuix 主题、AuthManager(EncryptedPrefs 存 JWT、401→auth_expired→登出)。
+3. `data`:Retrofit(login/sessions/messages/chat)、WS `ChatSocket`(`/ws/chat?token=`,事件分发到 StateFlow)、`ApiClient`(`Authorization: Bearer`+`X-API-Version`)。
+4. `domain`:`Conference`/`ChatMessage` 模型、`HistoryMessage` 映射、`
`/`/` 解析器。
+5. `ui`:`LoginScreen` → `HomeScreen`(会话列表)→ `ChatScreen`(气泡流 + 流式 + 思维链/工具卡 + 输入框)。顶栏汉堡侧边栏(IoT/设置/登出)。
+6. 设备标识:`deviceId=voiceassist:myapp:v1`。
+7. 验证:登录 → 拉主会话历史 → 发消息 → WS 流式收到回复 → 跨端(web/微信)同步显示。
+
+## 8. 长线 roadmap
+- **P0(纯文本)**:⑦ 完成 → 可日常聊天,多端同步。
+- **P1(语音助手)**:后端补 ASR/TTS(design/21)→ 电源键长按唤起全屏助手 → 聆听/ASR/chat/TTS 朗读闭环 → 无悬浮球。
+- **P2(智能)**:IoT 入口真实现(设备/场景)、App 设置功能化(主题/通知/语音偏好)、主动推送 + 系统通知、多角色(persona)切换、记忆/子代理/自主思考只读面板、多会话管理增强。
+
+## 9. 开放问题(待与主人讨论 / 后续敲定)
+1. **[已定案] 长按电源键**:设置 App 为**系统默认语音助手**(`VOICE_INTERACTION_SERVICE` / `AssistantService`),由系统在长按电源键(或系统语音助手入口)时拉起本 App 的 `AssistantActivity`(全屏),无悬浮球。厂商(MIUI)自家更顺,需在系统「语音助手 / 电源键」设置里把本 App 设为默认。
+2. **[已定案] ASR/TTS 后端化**:**不在端侧**——因第三方适配器(微信 / KOOK 等 IM 渠道语音)**无法在端侧实现**,且为大后期**语音 / 视频通话**打基础。语音闭环统一走后端真实 ASR / TTS。**依赖**:后端 `voice` 链路当前是降级占位(cloud endpoint 空 → 降级、silent 空音频),需先接通真实 ASR / TTS(本会话 TTS/ASR 进程在做)。Android 语音 P1 依赖这项完成后接入。
+3. **Miuix 组件精确映射**:到 `components` 页逐组件对照,列出"我们用哪个"。
+4. **多角色**:现在登录恒 owner,暂无双角色切换;`GET /personas` 可只读展示人格列表。
+5. **会话管理 UI**:次级会话的新建/切换/删除入口。
+6. **Hilt vs 手写 DI**:视复杂度定。
+
+## 10. 红线(必须遵守)
+- JWT 存储加密(EncryptedPrefs),不落明文/不打日志;401 即登出。
+- 身份靠 actor 元数据 + owner.yaml 白名单(登录即 owner),不靠提示词。
+- 语音/音频:权限最小化、显式授权;**不做悬浮球全局浮层**。
+- 严格 Miuix,禁原生 Material;`Docs/api-reference.md` 契约随实现更新。
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f459e5b
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,15 @@
+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
+
+# 本机无法访问 dl.google.com,禁止 AGP 尝试联网下载 SDK 组件(否则会卡住)
+android.builder.sdkDownload=false
+
+kotlin.code.style=official
+
+# ARM64 机器:Google Maven 只提供 x86_64 的 aapt2,改用本机 ARM64 版本
+android.aapt2FromMavenOverride=/home/Aska/Android/Sdk/build-tools/37.0.0/aapt2
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..7ea4143
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,47 @@
+[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"
+
+[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" }
+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" }
+
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..2c35211
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..5d85670
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,8 @@
+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
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..f5feea6
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/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" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..9b42019
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@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
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..761ab3f
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,23 @@
+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")