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\\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("^$") +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", ">", "你", "", "笑", + "", "", "*", "动", + ) + 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")