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 基础模块:消息内容解析器(<p>/<action>/<narration> + 流式增量)、WebSocket 客户端、REST 数据层与分页去重。
45 个单元测试全绿,真机验证通过(登录页 + 启动图标)。
devdocs/ 收录设计文档、前后端协同事项与细化方案。
This commit is contained in:
AskaEth committed 2026-09-08 20:40:58 +08:00
commit 84de51c7b7
67 files changed
+3960

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
*.iml
.gradle/
build/
local.properties
.idea/
.DS_Store
captures/
.externalNativeBuild/
.cxx/
+75
View File
@@ -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)
}
+10
View File
@@ -0,0 +1,10 @@
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class **$$serializer {
*** descriptor;
}
-keepclasseswithmembers class ** {
kotlinx.serialization.KSerializer serializer(...);
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".app.CyreneApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Cyrene">
<activity
android:name=".app.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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)
}
@@ -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() },
)
}
}
@@ -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)
}
}
@@ -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)
}
}
}
}
@@ -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>(SessionState.Unknown)
val state: StateFlow<SessionState> = _state.asStateFlow()
private var api: CyreneApi? = null
var currentBaseUrl: String = ""
private set
/** 启动时:有效 token 直接进入;过期则用已存凭据静默重登(D12);否则回登录页。 */
suspend fun bootstrap() = withContext(Dispatchers.IO) {
val stored = store.load()
currentBaseUrl = stored.baseUrl
when {
!stored.token.isNullOrBlank() && stored.expiresAt * 1000 > System.currentTimeMillis() -> {
_state.value = SessionState.LoggedIn(stored.baseUrl, stored.role.orEmpty())
}
!stored.username.isNullOrBlank() && !stored.password.isNullOrBlank() -> {
val error = doLogin(stored.baseUrl, stored.username, stored.password)
if (error != null) _state.value = SessionState.LoggedOut
}
else -> _state.value = SessionState.LoggedOut
}
}
suspend fun login(rawBaseUrl: String, username: String, password: String): CyreneError? =
withContext(Dispatchers.IO) {
doLogin(ApiClientFactory.normalizeBaseUrl(rawBaseUrl), username, password)
}
fun logout() {
store.clearAll()
api = null
_state.value = SessionState.LoggedOut
}
/** 会话过期(401):清 token、回登录页;凭据保留以便下次静默重登。 */
fun onSessionExpired() {
store.clearSession()
api = null
_state.value = SessionState.LoggedOut
}
private suspend fun doLogin(url: String, username: String, password: String): CyreneError? {
val client = ApiClientFactory.create(baseUrl = url)
return try {
val response = client.login(LoginRequest(username, password))
val data = response.body()?.data
when {
response.isSuccessful && data != null -> {
store.saveLogin(url, data.token, data.expiresAt, data.role, username, password)
api = client
currentBaseUrl = url
_state.value = SessionState.LoggedIn(url, data.role)
null
}
response.isSuccessful -> CyreneError.Local(CyreneError.LocalKind.Parse)
else -> {
val parsed = parseError(response.errorBody()?.string())
if (response.code() == 401) {
CyreneError.Unauthorized(parsed?.code.orEmpty(), parsed?.message.orEmpty())
} else {
CyreneError.Server(
code = parsed?.code.orEmpty(),
message = parsed?.message.orEmpty(),
httpStatus = response.code(),
)
}
}
}
} catch (e: Exception) {
mapException(e)
}
}
private fun parseError(body: String?) = body
?.takeIf { it.isNotBlank() }
?.let {
runCatching {
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
}.getOrNull()
}
private fun mapException(e: Throwable): CyreneError = when (e) {
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
}
}
@@ -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"
}
}
@@ -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,
}
}
@@ -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)
}
@@ -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
}
}
}
@@ -0,0 +1,21 @@
package top.yeij.cyrene.data.api
import kotlinx.serialization.Serializable
/** 后端统一信封:成功 `{data}`,失败 `{error:{code,message}}`。 */
@Serializable
data class ApiEnvelope<T>(
val data: T? = null,
val error: ApiError? = null,
)
@Serializable
data class ApiErrorEnvelope(
val error: ApiError? = null,
)
@Serializable
data class ApiError(
val code: String = "",
val message: String = "",
)
@@ -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<T>` 统一传递失败。 */
class CyreneException(val error: CyreneError) : Exception(error.userMessage)
/**
* 执行一次信封调用并把结果收敛成 `Result<T>`:
* 成功且 `data != null` → success;成功但 `data == null` → Parse;否则走 [ApiErrorMapper]。
*/
suspend fun <T : Any> apiCall(block: suspend () -> Response<ApiEnvelope<T>>): Result<T> =
try {
val response = block()
val data = response.body()?.data
when {
response.isSuccessful && data != null -> Result.success(data)
response.isSuccessful -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
else -> Result.failure(
CyreneException(
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
),
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
@@ -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<ApiEnvelope<LoginResponse>>
}
@@ -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<ApiEnvelope<List<SessionSummaryDto>>>
/** 无 body;服务端返回 `201 {data:{sessionId}}`。 */
@POST("api/v1/sessions")
suspend fun createSession(): Response<ApiEnvelope<CreateSessionResponse>>
/** 主会话 `web` 删除会返回 `403 forbidden`。 */
@DELETE("api/v1/sessions/{id}")
suspend fun deleteSession(@Path("id") sessionId: String): Response<ApiEnvelope<DeleteSessionResponse>>
/**
* 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。
* @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制)
* @param before 上一页最旧消息的 `createdAt`(Unix 毫秒);null = 最新一页
* @param platform 来源平台过滤;null = 全量
*/
@GET("api/v1/sessions/{id}/messages")
suspend fun listMessages(
@Path("id") sessionId: String,
@Query("limit") limit: Int? = null,
@Query("before") before: Long? = null,
@Query("platform") platform: String? = null,
): Response<ApiEnvelope<List<HistoryMessageDto>>>
}
@@ -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,
)
@@ -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,
)
@@ -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:<uuid>"}}`。 */
@Serializable
data class CreateSessionResponse(
val sessionId: String,
)
/** `DELETE /api/v1/sessions/{id}` → `200 {"data":{"deleted":"<id>"}}`。 */
@Serializable
data class DeleteSessionResponse(
val deleted: String,
)
@@ -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<PagedMessages> = withContext(dispatcher) {
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
apiCall { api.listMessages(sessionId, pageSize, before, platform) }
.map { raw -> toPagedMessages(raw, pageSize) }
}
/** 首屏 / 刷新:最新一页。 */
suspend fun loadLatest(
sessionId: String,
limit: Int = DEFAULT_PAGE_SIZE,
platform: String? = null,
): Result<PagedMessages> = loadPage(sessionId, limit, before = null, platform = platform)
companion object {
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 200
}
}
private fun toPagedMessages(
rawNewestFirst: List<HistoryMessageDto>,
pageSize: Int,
): PagedMessages {
val ascending = rawNewestFirst.sortedWith(MessageOrder)
return PagedMessages(
messages = ascending,
hasMore = rawNewestFirst.size >= pageSize,
oldestCreatedAt = ascending.firstOrNull()?.createdAt,
newestCreatedAt = ascending.lastOrNull()?.createdAt,
)
}
@@ -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<HistoryMessageDto>,
val hasMore: Boolean,
val oldestCreatedAt: Long?,
val newestCreatedAt: Long?,
)
/** 消息排序:`createdAt` 升序,同毫秒用 `id` 兜底,保证稳定可复现。 */
internal val MessageOrder: Comparator<HistoryMessageDto> = compareBy<HistoryMessageDto>({ it.createdAt }, { it.id })
/**
* 纯函数合并:`existing`(本地已加载,任意序)+ `incoming`(服务端新到的一页,倒序)
* → 按 `id` 去重、按 `createdAt` 升序(同毫秒用 `id` 兜底)的结果。
*
* 冲突时 **incoming 胜出**(§3.2「服务端优先」)。不修改入参,返回新列表。
* 时间复杂度 O((n+m) log(n+m)),空间 O(n+m)。
*/
fun mergeMessages(
existing: List<HistoryMessageDto>,
incoming: List<HistoryMessageDto>,
): List<HistoryMessageDto> {
if (incoming.isEmpty()) return existing.sortedWith(MessageOrder)
if (existing.isEmpty()) return incoming.sortedWith(MessageOrder)
val byId = LinkedHashMap<String, HistoryMessageDto>(existing.size + incoming.size)
existing.forEach { byId[it.id] = it }
incoming.forEach { byId[it.id] = it }
return byId.values.sortedWith(MessageOrder)
}
@@ -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<List<SessionSummaryDto>> = withContext(dispatcher) {
apiCall { api.listSessions() }
}
/** `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:<uuid>`)。 */
suspend fun createSession(): Result<String> = withContext(dispatcher) {
apiCall { api.createSession() }.map { it.sessionId }
}
/** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
apiCall { api.deleteSession(sessionId) }.map { it.deleted }
}
}
@@ -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=<JWT>`,api-reference §6)。
*
* ## 用法
* ```
* val socket = ChatSocket(baseUrlProvider = { cfg.baseUrl }, tokenProvider = { auth.token },
* onAuthFailed = { auth.logout() })
* socket.connect()
* // 收集 socket.events,观察 socket.state
* socket.sendChat(sessionId = "web", text = "你好", deviceId = deviceId)
* socket.close()
* ```
*
* ## 会话归属约束
* `message.*` / `thinking.*` / `tool_call.*` / `stream_chunk` 等流式帧**不带 `sessionId`**,
* 本类不会替它们编造会话。上层必须把它们归属到「当前唯一的 active 流式会话」——
* 也就是**同一时刻只允许一个会话在流式**(切会话时需先结束或 `interrupt`)。
*
* ## 心跳与重连
* - OkHttp `pingInterval` 30s(服务端 ping 周期 54s / pong 等待 60s,客户端 30s 足够)。
* - 服务端**没有 resume / 补发**:重连成功后由上层用新连接的 `history` 帧 + REST 补差去重。
* - 退避 1s/2s/4s/8s/16s/30s(上限 30s,含抖动)。
* - 握手返回 **HTTP 401** → [WsState.Offline],触发 [onAuthFailed],**不再重连**(需重新登录)。
*
* ## 坏帧
* 每帧解析都走 [WsEvent.parse],非 JSON / 未知 `type` / 结构不匹配一律丢弃,不会中断连接。
*
* @param baseUrlProvider 服务器根地址(如 `http://host:8080`),每次重连时重新读取。
* @param tokenProvider 当前 JWT,每次重连时重新读取(支持静默重登后复用同一实例)。
* @param onAuthFailed 握手 401 回调;在协程内调用,注意切主线程。
* @param client 可注入自定义 OkHttpClient(会 `newBuilder` 派生,保留其拦截器);缺省新建。
*/
class ChatSocket(
private val baseUrlProvider: () -> String?,
private val tokenProvider: () -> String?,
private val onAuthFailed: () -> Unit = {},
client: OkHttpClient? = null,
) {
private val _state = MutableStateFlow(WsState.Idle)
/** 连接状态机;见 [WsState]。 */
val state: StateFlow<WsState> = _state.asStateFlow()
private val _events = MutableSharedFlow<WsEvent>(
replay = 0,
extraBufferCapacity = EVENT_BUFFER,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/**
* 下行事件流。
*
* `replay = 0`:重放旧 delta 会让新订阅者重复渲染,会话状态应由 `history` / REST 重建。
* `extraBufferCapacity = 512 + DROP_OLDEST`:发送方是 OkHttp 读线程,不能阻塞;溢出时丢最旧的
* 增量帧(服务端发送缓冲 32 本就会丢帧,`stream_end` / `history` 仍可兜底)。
*/
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
private val wsClient: OkHttpClient = (client ?: OkHttpClient()).newBuilder()
.pingInterval(PING_INTERVAL_SECONDS, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
@Volatile
private var wantConnection: Boolean = false
@Volatile
private var webSocket: WebSocket? = null
@Volatile
private var attempt: Int = 0
@Volatile
private var sessionJob: Job? = null
private var scope: CoroutineScope? = null
/** 开始连接(幂等:已在连接 / 重连中时忽略)。 */
fun connect() {
if (wantConnection) return
wantConnection = true
attempt = 0
sessionJob = ensureScope().launch { runConnectionLoop() }
}
/** 主动断开并停掉重连;之后可再次 [connect]。 */
fun close() {
wantConnection = false
val socket = webSocket
webSocket = null
socket?.close(CLOSE_CODE_NORMAL, CLOSE_REASON_CLIENT)
sessionJob?.cancel()
sessionJob = null
_state.value = WsState.Idle
scope?.cancel()
scope = null
}
/**
* 发送 `chat`(api-reference §6.3)。
* @return 未连接或写失败返回 `false`(本层不做离线队列,由上层标红重试)。
*/
fun sendChat(sessionId: String?, text: String, deviceId: String?): Boolean =
send(WsOutbound.Chat(text = text, sessionId = sessionId, deviceId = deviceId))
/** 发送 `interrupt`(取消在跑 turn + 清空 pending)。 */
fun sendInterrupt(sessionId: String): Boolean = send(WsOutbound.Interrupt(sessionId))
private fun send(outbound: WsOutbound): Boolean {
if (_state.value != WsState.Connected) return false
val socket = webSocket ?: return false
return runCatching { socket.send(outbound.toJson()) }.getOrDefault(false)
}
private suspend fun runConnectionLoop() {
try {
while (wantConnection) {
when (val end = runSession()) {
SessionEnd.ConfigMissing -> {
wantConnection = false
_state.value = WsState.Offline
}
SessionEnd.AuthFailed -> {
wantConnection = false
_state.value = WsState.Offline
onAuthFailed()
}
SessionEnd.Closed -> {
if (!wantConnection) break
_state.value = WsState.Reconnecting
delay(backoffDelayMs(attempt))
attempt += 1
}
}
}
} catch (_: CancellationException) {
// close() 主动取消,正常退出。
}
}
/** 建立一次连接并挂起,直到它关闭 / 失败 / 被取消。 */
private suspend fun runSession(): SessionEnd {
val url = buildUrl() ?: return SessionEnd.ConfigMissing
_state.value = if (attempt == 0) WsState.Connecting else WsState.Reconnecting
val finished = CompletableDeferred<SessionEnd>()
val socket = wsClient.newWebSocket(
Request.Builder().url(url).build(),
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
attempt = 0
_state.value = WsState.Connected
}
override fun onMessage(webSocket: WebSocket, text: String) {
// 未知 type / 坏帧由 parse 返回 null,静默丢弃。
WsEvent.parse(text)?.let { _events.tryEmit(it) }
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
finished.complete(SessionEnd.Closed)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val end = if (isAuthFailure(response, t)) SessionEnd.AuthFailed else SessionEnd.Closed
finished.complete(end)
}
},
)
webSocket = socket
return try {
finished.await()
} finally {
if (webSocket === socket) webSocket = null
socket.cancel()
}
}
/**
* 握手鉴权失败是**握手前**的 HTTP 401(不是 WS close),OkHttp 会把它随 [onFailure] 的
* `response` 一起回调;个别路径拿不到 response 时再退化为消息文本匹配。
*/
private fun isAuthFailure(response: Response?, t: Throwable): Boolean {
if (response?.code == 401) return true
return t.message?.contains("401") == true
}
private fun buildUrl(): HttpUrl? {
val token = tokenProvider()?.takeIf { it.isNotBlank() } ?: return null
val base = baseUrlProvider()?.takeIf { it.isNotBlank() } ?: return null
val httpUrl = ApiClientFactory.normalizeBaseUrl(base).toHttpUrlOrNull() ?: return null
return httpUrl.newBuilder()
.addPathSegments("ws/chat")
.addQueryParameter("token", token)
.build()
}
/** 1s/2s/4s/8s/16s/30s,上限 30s;加 0~25% 抖动避免同时重连。 */
private fun backoffDelayMs(attempt: Int): Long {
val shift = attempt.coerceIn(0, MAX_BACKOFF_SHIFT)
val base = (INITIAL_BACKOFF_MS shl shift).coerceAtMost(MAX_BACKOFF_MS)
val jitter = Random.nextLong(base / JITTER_DIVISOR + 1)
return (base + jitter).coerceAtMost(MAX_BACKOFF_MS)
}
private fun ensureScope(): CoroutineScope =
scope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
private enum class SessionEnd { Closed, AuthFailed, ConfigMissing }
private companion object {
const val PING_INTERVAL_SECONDS = 30L
const val CONNECT_TIMEOUT_SECONDS = 10L
const val INITIAL_BACKOFF_MS = 1_000L
const val MAX_BACKOFF_MS = 30_000L
const val MAX_BACKOFF_SHIFT = 5
const val JITTER_DIVISOR = 4L
const val EVENT_BUFFER = 512
const val CLOSE_CODE_NORMAL = 1000
const val CLOSE_REASON_CLIENT = "client closed"
}
}
@@ -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-<hex>`);OnStream 结构化流路径不带。 */
open val turnId: String? = null
/** hub 级单调递增序号,供排序关联流式突发。 */
open val seq: Long? = null
/** 能确定会话的事件才有;流式正文/思维链/工具帧恒为 `null`。 */
open val sessionId: String? = null
/** 连接首帧:主会话最近 50 条历史(`thinking` 已被服务端剥离)。 */
data class History(
val messages: List<HistoryMessageDto>,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "history"
}
/** 正文段开始。 */
data class MessageStart(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "message.start"
}
/** 正文 token 增量(累加到当前正文段)。 */
data class MessageDelta(
val delta: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "message.delta"
}
/** 正文段结束(不结束整轮,整轮由 [StreamEnd] 收尾)。 */
data class MessageEnd(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "message.end"
}
/** 思维链段开始。 */
data class ThinkingStart(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "thinking.start"
}
/** 思维链 token 增量。 */
data class ThinkingDelta(
val delta: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "thinking.delta"
}
/** 思维链段结束。 */
data class ThinkingEnd(
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "thinking.end"
}
/** 工具调用中间态开始。 */
data class ToolCallStart(
val id: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "tool_call.start"
}
/** 工具调用参数增量(如 `input_json_delta`)。 */
data class ToolCallDelta(
val id: String,
val index: Int,
val delta: String,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "tool_call.delta"
}
/** 工具调用中间态结束。 */
data class ToolCallEnd(
val id: String,
val index: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "tool_call.end"
}
/** 工具执行进度 / 局部输出。 */
data class ToolExecutionUpdate(
val id: String,
val index: Int,
val delta: String,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "tool_execution.update"
}
/** 向后兼容整段文本(仅非流式回退路径;流式路径不 emit)。 */
data class StreamChunk(
val delta: String,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "stream_chunk"
}
/**
* 一轮结束。`stopReason` 取值未穷举(`stop` / `idle` / `error` / …),用字符串承载;
* `error` 形状未定义(字符串或对象都可能),原样以 [JsonElement] 暴露。
*/
data class StreamEnd(
val stopReason: String,
val steps: Int? = null,
val error: JsonElement? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "stream_end"
}
/** 会话进入忙碌态。`mode` ∈ `idle` / `thinking` / `executing` / `subagent`。 */
data class SessionBusy(
override val sessionId: String,
val mode: String,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_busy"
}
/** 忙时新消息已入队(「收到,稍后回」)。 */
data class PendingAck(
override val sessionId: String,
val mode: String,
val queueDepth: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "pending_ack"
}
/** 忙完开始消费下一条待处理消息。 */
data class PendingDrain(
override val sessionId: String,
val mode: String,
val queueDepth: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "pending_drain"
}
/** 会话回到空闲、队列清空。 */
data class SessionIdle(
override val sessionId: String,
val mode: String,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_idle"
}
/** 用户打断:当前任务取消、pending 清空,`dropped` 为丢弃条数。 */
data class SessionInterrupted(
override val sessionId: String,
val mode: String,
val dropped: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "session_interrupted"
}
/** 单会话 pending 达上限(`max`,默认 5),拒绝入队。 */
data class PendingFull(
override val sessionId: String,
val mode: String,
val max: Int,
override val turnId: String? = null,
override val seq: Long? = null,
) : WsEvent() {
override val type: String get() = "pending_full"
}
/** 主会话(`web` / `web:<uuid>`)消息落库后广播。 */
data class MessagePersisted(
val message: HistoryMessageDto?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "message_persisted"
}
/** 隔离会话(`sess-xxx`,群聊 / 陌生人)消息落库后广播,供 IM 适配器日志页。 */
data class ChannelPersisted(
val message: HistoryMessageDto?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "channel_persisted"
}
/** 语音合成回复(定向回请求连接)。 */
data class VoiceOutput(
val audio: String?,
val format: String?,
val text: String?,
val reply: String?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "voice_output"
}
/** 自主主动推送(仅 owner/admin 连接收到)。 */
data class ProactiveMessage(
val platform: String?,
val channelId: String?,
val channelType: String?,
val content: String,
val contentType: String?,
val sender: String?,
val timestamp: Long?,
val messageId: String?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "proactive_message"
}
/** 服务端错误(非法 JSON / 未知类型 / 语音·子代理·任务错误等)。 */
data class Error(
val message: String,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "error"
}
companion object {
/**
* 解析一帧文本。**永不抛异常**:非 JSON、缺 `type`、未知 `type`、`data` 结构不匹配
* 都返回 `null`,由调用方安全忽略。
*/
fun parse(raw: String): WsEvent? {
val root = runCatching { ApiClientFactory.json.parseToJsonElement(raw) }
.getOrNull() as? JsonObject ?: return null
val type = root["type"].asStringOrNull() ?: return null
val turnId = root["turnId"].asStringOrNull()
val seq = root["seq"].asStringOrNull()?.toLongOrNull()
val sessionId = root["sessionId"].asStringOrNull()
val data = root["data"]?.takeIf { it !is JsonNull }
return runCatching { dispatch(type, turnId, seq, sessionId, data) }.getOrNull()
}
private fun dispatch(
type: String,
turnId: String?,
seq: Long?,
sessionId: String?,
data: JsonElement?,
): WsEvent? = dispatchStreaming(type, turnId, seq, sessionId, data)
?: dispatchSession(type, turnId, seq, sessionId, data)
private fun dispatchStreaming(
type: String,
turnId: String?,
seq: Long?,
sessionId: String?,
data: JsonElement?,
): WsEvent? = when (type) {
"history" -> History(decode<HistoryData>(data).messages, turnId, seq, sessionId)
"message.start" -> MessageStart(decode<IndexData>(data).index, turnId, seq)
"message.delta" -> decode<DeltaIndexData>(data).let { MessageDelta(it.delta, it.index, turnId, seq) }
"message.end" -> MessageEnd(decode<IndexData>(data).index, turnId, seq)
"thinking.start" -> ThinkingStart(decode<IndexData>(data).index, turnId, seq)
"thinking.delta" -> decode<DeltaIndexData>(data).let { ThinkingDelta(it.delta, it.index, turnId, seq) }
"thinking.end" -> ThinkingEnd(decode<IndexData>(data).index, turnId, seq)
"tool_call.start" -> decode<ToolCallIdData>(data).let { ToolCallStart(it.id, it.index, turnId, seq) }
"tool_call.delta" -> decode<ToolCallData>(data).let { ToolCallDelta(it.id, it.index, it.delta, turnId, seq) }
"tool_call.end" -> decode<ToolCallIdData>(data).let { ToolCallEnd(it.id, it.index, turnId, seq) }
"tool_execution.update" -> decode<ToolCallData>(data)
.let { ToolExecutionUpdate(it.id, it.index, it.delta, turnId, seq) }
"stream_chunk" -> StreamChunk(decode<StreamChunkData>(data).delta, turnId, seq, sessionId)
"stream_end" -> decode<StreamEndData>(data)
.let { StreamEnd(it.stopReason, it.steps, it.error, turnId, seq, sessionId) }
else -> null
}
private fun dispatchSession(
type: String,
turnId: String?,
seq: Long?,
sessionId: String?,
data: JsonElement?,
): WsEvent? = when (type) {
"session_busy" -> decode<SessionModeData>(data)
.let { SessionBusy(resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq) }
"pending_ack" -> decode<PendingData>(data)
.let { PendingAck(resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq) }
"pending_drain" -> decode<PendingData>(data)
.let { PendingDrain(resolveSessionId(sessionId, it.sessionId), it.mode, it.queueDepth, turnId, seq) }
"session_idle" -> decode<SessionModeData>(data)
.let { SessionIdle(resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq) }
"session_interrupted" -> decode<InterruptedData>(data)
.let { SessionInterrupted(resolveSessionId(sessionId, it.sessionId), it.mode, it.dropped, turnId, seq) }
"pending_full" -> decode<PendingFullData>(data)
.let { PendingFull(resolveSessionId(sessionId, it.sessionId), it.mode, it.max, turnId, seq) }
"message_persisted" -> decode<PersistedData>(data)
.let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
"channel_persisted" -> decode<PersistedData>(data)
.let { ChannelPersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
"voice_output" -> decode<VoiceOutputData>(data)
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
"proactive_message" -> decode<ProactiveData>(data).let {
ProactiveMessage(
it.platform, it.channelId, it.channelType, it.content, it.contentType,
it.sender, it.timestamp, it.messageId, turnId, seq, sessionId,
)
}
"error" -> Error(decode<ErrorData>(data).message, turnId, seq, sessionId)
else -> null
}
private fun resolveSessionId(shell: String?, fromData: String): String =
fromData.ifBlank { shell ?: "" }
private inline fun <reified T> decode(data: JsonElement?): T =
ApiClientFactory.json.decodeFromJsonElement<T>(data ?: JsonObject(emptyMap()))
}
}
private fun JsonElement?.asStringOrNull(): String? {
val primitive = this as? JsonPrimitive ?: return null
if (primitive is JsonNull) return null
return primitive.content
}
@Serializable
private data class IndexData(val index: Int = 0)
@Serializable
private data class DeltaIndexData(val delta: String = "", val index: Int = 0)
@Serializable
private data class ToolCallIdData(val id: String = "", val index: Int = 0)
@Serializable
private data class ToolCallData(val id: String = "", val index: Int = 0, val delta: String = "")
@Serializable
private data class HistoryData(val messages: List<HistoryMessageDto> = emptyList())
@Serializable
private data class StreamChunkData(val delta: String = "")
@Serializable
private data class StreamEndData(
val stopReason: String = "",
val steps: Int? = null,
val error: JsonElement? = null,
)
@Serializable
private data class SessionModeData(val sessionId: String = "", val mode: String = "")
@Serializable
private data class PendingData(val sessionId: String = "", val mode: String = "", val queueDepth: Int = 0)
@Serializable
private data class InterruptedData(val sessionId: String = "", val mode: String = "", val dropped: Int = 0)
@Serializable
private data class PendingFullData(val sessionId: String = "", val mode: String = "", val max: Int = 0)
@Serializable
private data class PersistedData(val message: HistoryMessageDto? = null)
@Serializable
private data class VoiceOutputData(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val reply: String? = null,
)
@Serializable
private data class ProactiveData(
val platform: String? = null,
val channelId: String? = null,
val channelType: String? = null,
val content: String = "",
val contentType: String? = null,
val sender: String? = null,
val timestamp: Long? = null,
val messageId: String? = null,
)
@Serializable
private data class ErrorData(val message: String = "")
@@ -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) }
}
}
@@ -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,
}
@@ -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`(行为对齐基准)。
*
* 语义摘要:
* - `<p>…</p>`:每个完整块 = 一条独立气泡;无完整 `<p>` 时回退按空行 `\n\s*\n` 切分;
* 单个 `\n` 是句内换行,绝不切分。未闭合的 `<p>` 不构成块,按普通文本处理(流式下会先缓存)。
* `<p>` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
* - `<action>…</action>` / `<narration>…</narration>`:旁白/动作,不独立成气泡,依附气泡:
* 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
* 关键词 旁白/内心 → narration,其余 → action。
* - 未闭合/畸形的标签一律回落为普通文本,绝不抛异常。
*
* `ParsedBubble` 结构(每条气泡渲染一个):
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
* 无正文时 `body` 为空串,仅 `head` 承载整条消息(纯动作/旁白气泡)。
* `parse("")` / 纯空白输入返回空列表;纯空白且无旁白的气泡会被丢弃。
*
* 流式边界决策(`StreamingContentParser`):
* - `onDelta` 返回「当前已确定前缀」的解析结果;`finish` 返回全量缓冲的解析结果,
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
* - 标签残缺判断只影响「何时产出」,不影响最终结果;扣留期间不会抛异常。
* - 本类无副作用、非线程安全,调用方保证单线程喂入即可。
*/
/** 气泡内的一个有序片段。 */
sealed interface ContentPart {
val text: String
data class Text(override val text: String) : ContentPart
data class Action(override val text: String) : ContentPart
data class Narration(override val text: String) : ContentPart
}
/**
* 一条可渲染的气泡:正文 + 有序片段 + 上/下依附的旁白块。
* 字段含义见文件头注释;`body` 为空且 `head` 非空时,表示只有旁白没有正文。
*/
data class ParsedBubble(
val body: String,
val parts: List<ContentPart>,
val head: List<ContentPart>,
val tail: List<ContentPart>,
) {
val isEmpty: Boolean
get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
}
/** 完整 `<p>…</p>` 块(允许 `class` 等属性,但要求标签名后为空白或直接 `>`)。 */
private val P_BLOCK_RE = Regex("<p(\\s[^>]*)?>([\\s\\S]*?)</p>")
/**
* 一次匹配旁白的三种等价写法;左侧优先(最靠左的匹配获胜),
* 三种 body 均非贪婪,可跨换行;未闭合/畸形则整段回落为文本。
* 捕获组:1 = 标签名,2 = 标签 body,3 = 全角星 body,4 = 半角星 body。
*/
private val ROLE_RE = Regex(
"<\\s*(action|narration)\\s*>([\\s\\S]*?)</\\s*\\1\\s*>" +
"|*([\\s\\S]*?)*" +
"|\\*((?:动作|旁白|内心|神态|轻声)\\s*[::]?[\\s\\S]*?)\\*",
)
/** 星号块开头的关键词(可带中/英文冒号)。 */
private val ROLE_KEYWORD_RE = Regex("^(动作|旁白|内心|神态|轻声)\\s*[::]?\\s*")
private val ROLE_KEYWORDS = listOf("动作", "旁白", "内心", "神态", "轻声")
/** 空行切分:`\n` + 若干空白 + `\n`。 */
private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
/** 无关键词的全角星块超过此长度即视为普通文本(对齐 Web `STAR_SHORT_RUNES`)。 */
private const val FULL_STAR_MAX_LEN = 40
private val TAG_NAMES = listOf("action", "narration", "p")
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
private val OPEN_P_TAG_RE = Regex("^<p(\\s[^>]*)?>$")
private val CLOSE_P_TAG_RE = Regex("^</p>$")
/** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */
fun parse(raw: String): List<ParsedBubble> {
if (raw.isEmpty()) return emptyList()
return buildBubbles(tokenize(raw))
}
/**
* 带缓冲的流式增量解析器:`onDelta` 逐段喂入,`finish` 收口。
* 无副作用;调用方保证单线程访问。
*/
class StreamingContentParser {
private val buffer = StringBuilder()
/** 当前累计的原始文本(含尚未确定、被扣留的尾部)。 */
val raw: String
get() = buffer.toString()
/** 追加一段 delta,返回「当前已确定前缀」解析出的气泡列表。 */
fun onDelta(delta: String): List<ParsedBubble> {
if (delta.isNotEmpty()) buffer.append(delta)
val current = buffer.toString()
return parse(current.substring(0, stablePrefixEnd(current)))
}
/** 收口:对全部缓冲做一次性解析,结果与 `parse(拼接的全部 delta)` 完全一致。 */
fun finish(): List<ParsedBubble> = parse(buffer.toString())
/** 清空缓冲,可复用本实例处理下一条消息。 */
fun reset() {
buffer.setLength(0)
}
}
// ---------------------------------------------------------------------------
// 内部实现
// ---------------------------------------------------------------------------
private sealed interface Tok {
data class Txt(val text: String) : Tok
data class Act(val text: String) : Tok
data class Nar(val text: String) : Tok
object Break : Tok
}
/** 先切 `<p>` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */
private fun tokenize(raw: String): List<Tok> {
val pMatches = P_BLOCK_RE.findAll(raw).toList()
val out = ArrayList<Tok>()
if (pMatches.isEmpty()) {
appendAnnotations(raw, out, splitBlankLines = true)
return out
}
var last = 0
for (m in pMatches) {
val lead = raw.substring(last, m.range.first)
if (lead.isNotEmpty()) appendAnnotations(lead, out, splitBlankLines = false)
out.add(Tok.Break)
val inner = m.groupValues[2]
if (inner.isNotEmpty()) appendAnnotations(inner, out, splitBlankLines = false)
out.add(Tok.Break)
last = m.range.last + 1
}
val trail = raw.substring(last)
if (trail.isNotEmpty()) appendAnnotations(trail, out, splitBlankLines = false)
return out
}
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
private fun appendAnnotations(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
var last = 0
for (m in ROLE_RE.findAll(text)) {
val before = text.substring(last, m.range.first)
if (before.isNotEmpty()) appendText(before, out, splitBlankLines)
when (val part = classify(m)) {
null -> appendText(m.value, out, splitBlankLines)
is ContentPart.Text -> appendText(part.text, out, splitBlankLines)
is ContentPart.Action -> out.add(Tok.Act(part.text))
is ContentPart.Narration -> out.add(Tok.Nar(part.text))
}
last = m.range.last + 1
}
val tail = text.substring(last)
if (tail.isNotEmpty()) appendText(tail, out, splitBlankLines)
}
/** 追加普通文本;无 `<p>` 时按空行插入气泡边界。 */
private fun appendText(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
if (!splitBlankLines) {
out.add(Tok.Txt(text))
return
}
var last = 0
for (m in BLANK_LINE_RE.findAll(text)) {
val piece = text.substring(last, m.range.first)
if (piece.isNotEmpty()) out.add(Tok.Txt(piece))
out.add(Tok.Break)
last = m.range.last + 1
}
val tail = text.substring(last)
if (tail.isNotEmpty()) out.add(Tok.Txt(tail))
}
/** 把一次正则匹配归类;返回 null 表示「整段保持普通文本」。 */
private fun classify(m: MatchResult): ContentPart? {
if (m.groups[1] != null) {
val name = m.groupValues[1]
val body = m.groupValues[2]
return if (name == "action") ContentPart.Action(body) else ContentPart.Narration(body)
}
if (m.groups[3] != null) return classifyFullStar(m.groupValues[3])
if (m.groups[4] != null) return classifyHalfStar(m.groupValues[4])
return null
}
/** 全角 `*…*`:有关键词按关键词定类,否则短且单行才当 action。 */
private fun classifyFullStar(body: String): ContentPart? {
matchRoleKeyword(body)?.let { return it }
val trimmed = body.trim()
val short = trimmed.isNotEmpty() && trimmed.length <= FULL_STAR_MAX_LEN
if (short && !trimmed.contains('\n') && !trimmed.contains('\r')) {
return ContentPart.Action(trimmed)
}
return null
}
/** 半角 `*…*`:正则已保证以关键词开头,这里只负责剥离关键词与定类。 */
private fun classifyHalfStar(body: String): ContentPart =
matchRoleKeyword(body) ?: ContentPart.Action(body.trim())
/** 匹配并剥离开头关键词;旁白/内心 → narration,其余 → action。 */
private fun matchRoleKeyword(body: String): ContentPart? {
val m = ROLE_KEYWORD_RE.find(body) ?: return null
val rest = body.substring(m.value.length).trim()
return if (m.groupValues[1] == "旁白" || m.groupValues[1] == "内心") {
ContentPart.Narration(rest)
} else {
ContentPart.Action(rest)
}
}
/** 以 Break 为界分组,把「只有旁白没有正文」的组并入相邻气泡,再逐组构造气泡。 */
private fun buildBubbles(tokens: List<Tok>): List<ParsedBubble> {
val groups = ArrayList<MutableList<Tok>>()
var current = ArrayList<Tok>()
for (t in tokens) {
if (t is Tok.Break) {
if (current.isNotEmpty()) groups.add(current)
current = ArrayList()
} else {
current.add(t)
}
}
if (current.isNotEmpty()) groups.add(current)
val merged = ArrayList<MutableList<Tok>>()
var carry = ArrayList<Tok>()
for (group in groups) {
val combined = ArrayList<Tok>(carry.size + group.size)
combined.addAll(carry)
combined.addAll(group)
carry = ArrayList()
if (hasBodyText(combined)) merged.add(combined) else carry = combined
}
if (carry.isNotEmpty()) {
if (merged.isNotEmpty()) merged.last().addAll(carry) else merged.add(carry)
}
return merged.mapNotNull { toBubble(it) }
}
private fun hasBodyText(tokens: List<Tok>): Boolean =
tokens.any { it is Tok.Txt && it.text.isNotBlank() }
/** 构造气泡:合并相邻文本、计算 body,并按首/尾规则分配 head / tail。 */
private fun toBubble(tokens: List<Tok>): ParsedBubble? {
val parts = ArrayList<ContentPart>(tokens.size)
for (t in tokens) {
when (t) {
is Tok.Txt -> {
val prev = parts.lastOrNull()
if (prev is ContentPart.Text) {
parts[parts.size - 1] = ContentPart.Text(prev.text + t.text)
} else {
parts.add(ContentPart.Text(t.text))
}
}
is Tok.Act -> parts.add(ContentPart.Action(t.text))
is Tok.Nar -> parts.add(ContentPart.Narration(t.text))
Tok.Break -> Unit
}
}
if (parts.isEmpty()) return null
val firstText = parts.indexOfFirst { it is ContentPart.Text }
val lastText = parts.indexOfLast { it is ContentPart.Text }
val head = ArrayList<ContentPart>()
val tail = ArrayList<ContentPart>()
val body = StringBuilder()
parts.forEachIndexed { i, part ->
if (part is ContentPart.Text) {
body.append(part.text)
} else if (firstText < 0 || i < firstText || (i > firstText && i < lastText)) {
head.add(part)
} else {
tail.add(part)
}
}
val bodyText = body.toString().trim()
if (bodyText.isEmpty() && head.isEmpty() && tail.isEmpty()) return null
return ParsedBubble(body = bodyText, parts = parts.toList(), head = head.toList(), tail = tail.toList())
}
// ---------------------------------------------------------------------------
// 流式边界:计算「可安全产出」的前缀长度
// ---------------------------------------------------------------------------
/** 返回当前缓冲中可安全解析的前缀长度;其后的未完成片段一律扣留。 */
private fun stablePrefixEnd(buf: String): Int {
var cut = buf.length
val openTags = ArrayDeque<Pair<String, Int>>()
var i = 0
while (i < buf.length) {
val lt = buf.indexOf('<', i)
if (lt < 0) break
val gt = buf.indexOf('>', lt)
if (gt < 0) {
if (isPotentialTagPrefix(buf, lt)) cut = min(cut, lt)
break
}
val tag = buf.substring(lt, gt + 1)
val openName = openTagName(tag)
if (openName != null) {
openTags.addLast(openName to lt)
} else {
val closeName = closeTagName(tag)
if (closeName == null) {
i = lt + 1
continue
}
val idx = openTags.indexOfLast { it.first == closeName }
if (idx >= 0) openTags.removeAt(idx)
}
i = gt + 1
}
if (openTags.isNotEmpty()) cut = min(cut, openTags.first().second)
val freeStart = buf.lastIndexOf('>') + 1
cut = withholdDanglingFullStar(buf, freeStart, cut)
cut = withholdDanglingHalfStar(buf, freeStart, cut)
return cut.coerceIn(0, buf.length)
}
private fun openTagName(tag: String): String? {
OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (OPEN_P_TAG_RE.matches(tag)) "p" else null
}
private fun closeTagName(tag: String): String? {
CLOSE_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (CLOSE_P_TAG_RE.matches(tag)) "p" else null
}
/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
private fun isPotentialTagPrefix(buf: String, lt: Int): Boolean {
var j = lt + 1
if (j < buf.length && buf[j] == '/') j++
while (j < buf.length && buf[j].isWhitespace()) j++
val nameStart = j
while (j < buf.length && buf[j] in 'a'..'z') j++
val name = buf.substring(nameStart, j)
if (j >= buf.length) return name.isEmpty() || TAG_NAMES.any { it.startsWith(name) }
if (name.isEmpty()) return false
if (!buf[j].isWhitespace()) return false
return TAG_NAMES.any { it.startsWith(name) }
}
/** 自由文本区内孤立的全角 `*`(计数为奇)从最后一个起扣留。 */
private fun withholdDanglingFullStar(buf: String, from: Int, cut: Int): Int {
var count = 0
var last = -1
for (i in from until buf.length) {
if (buf[i] == '*') {
count++
last = i
}
}
return if (count % 2 == 1 && last >= 0) min(cut, last) else cut
}
/** 自由文本区内孤立且可能开启关键词旁白的半角 `*` 从最后一个起扣留。 */
private fun withholdDanglingHalfStar(buf: String, from: Int, cut: Int): Int {
var count = 0
var last = -1
for (i in from until buf.length) {
if (buf[i] == '*') {
count++
last = i
}
}
if (count % 2 == 0 || last < 0) return cut
return if (couldStartRoleKeyword(buf.substring(last + 1))) min(cut, last) else cut
}
private fun couldStartRoleKeyword(after: String): Boolean {
if (after.isEmpty()) return true
return ROLE_KEYWORDS.any { it.startsWith(after) || after.startsWith(it) }
}
@@ -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)
}
}
}
@@ -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,
)
}
}
}
@@ -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))
}
}
}
@@ -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<LoginUiState> = _state.asStateFlow()
init {
val stored = sessionStore.load()
_state.update {
it.copy(
baseUrl = stored.baseUrl,
username = stored.username.orEmpty(),
serverExpanded = stored.baseUrl.isBlank(),
)
}
}
fun onBaseUrlChange(value: String) = _state.update { it.copy(baseUrl = value, error = null) }
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun togglePasswordVisible() = _state.update { it.copy(passwordVisible = !it.passwordVisible) }
fun toggleServerExpanded() = _state.update { it.copy(serverExpanded = !it.serverExpanded) }
fun submit() {
val snapshot = _state.value
if (snapshot.loading) return
val validationError = when {
snapshot.baseUrl.isBlank() -> "请填写服务器地址"
snapshot.username.isBlank() -> "请填写用户名"
snapshot.password.isBlank() -> "请填写密码"
else -> null
}
if (validationError != null) {
_state.update { it.copy(error = validationError, serverExpanded = true) }
return
}
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
val error = authManager.login(snapshot.baseUrl, snapshot.username.trim(), snapshot.password)
_state.update { it.copy(loading = false, error = error?.userMessage) }
}
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Cyrene" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/background_dark</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#F8F8F8</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Cyrene</string>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Cyrene" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@android:color/background_light</item>
</style>
</resources>
@@ -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<HistoryMessageDto>) = messages.map { it.id }
@Test
fun `overlapping page dedupes by id`() {
val existing = listOf(msg("m2", 200), msg("m1", 100))
val incoming = listOf(msg("m3", 300), msg("m2", 200))
val merged = mergeMessages(existing, incoming)
assertEquals(listOf("m1", "m2", "m3"), idsOf(merged))
}
@Test
fun `out of order incoming page is sorted ascending`() {
val existing = listOf(msg("m1", 100), msg("m2", 200))
val incoming = listOf(msg("m4", 400), msg("m3", 300))
val merged = mergeMessages(existing, incoming)
assertEquals(listOf("m1", "m2", "m3", "m4"), idsOf(merged))
}
@Test
fun `same millisecond falls back to id ordering`() {
val existing = listOf(msg("b", 100), msg("a", 100))
val incoming = listOf(msg("c", 100))
val merged = mergeMessages(existing, incoming)
assertEquals(listOf("a", "b", "c"), idsOf(merged))
}
@Test
fun `empty incoming page keeps existing`() {
val existing = listOf(msg("m1", 100), msg("m2", 200))
val merged = mergeMessages(existing, emptyList())
assertEquals(listOf("m1", "m2"), idsOf(merged))
}
@Test
fun `empty existing returns sorted incoming`() {
val incoming = listOf(msg("m3", 300), msg("m1", 100))
val merged = mergeMessages(emptyList(), incoming)
assertEquals(listOf("m1", "m3"), idsOf(merged))
}
@Test
fun `incoming wins on duplicate id`() {
val existing = listOf(msg("m1", 100, content = "old"))
val incoming = listOf(msg("m1", 100, content = "new"))
val merged = mergeMessages(existing, incoming)
assertEquals(1, merged.size)
assertEquals("new", merged.single().content)
}
@Test
fun `merge does not mutate inputs`() {
val existing = listOf(msg("m2", 200), msg("m1", 100))
val incoming = listOf(msg("m3", 300))
mergeMessages(existing, incoming)
assertEquals(listOf("m2", "m1"), idsOf(existing))
assertEquals(listOf("m3"), idsOf(incoming))
}
}
@@ -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("<p>第一</p><p>第二</p>")
assertEquals(listOf("第一", "第二"), bubbles.map { it.body })
}
@Test
fun blankLineFallbackSplitsBubbles() {
val bubbles = parse("第一段\n\n第二段")
assertEquals(listOf("第一段", "第二段"), bubbles.map { it.body })
}
@Test
fun blankLineWithWhitespaceAlsoSplits() {
val bubbles = parse("第一段\n \n第二段")
assertEquals(listOf("第一段", "第二段"), bubbles.map { it.body })
}
@Test
fun singleNewlineDoesNotSplit() {
assertEquals(listOf("第一行\n第二行"), parse("第一行\n第二行").map { it.body })
assertEquals(listOf("第一行\n第二行"), parse("<p>第一行\n第二行</p>").map { it.body })
}
@Test
fun emptyAndWhitespaceInputYieldsNoBubbles() {
assertTrue(parse("").isEmpty())
assertTrue(parse(" ").isEmpty())
assertTrue(parse("<p> </p>").isEmpty())
}
@Test
fun unclosedParagraphStaysLiteralText() {
assertEquals(listOf("<p>第一"), parse("<p>第一").map { it.body })
}
@Test
fun textOutsideParagraphsIsPreserved() {
val bubbles = parse("<action>笑</action><p>你好</p>")
assertEquals(1, bubbles.size)
assertEquals("你好", bubbles[0].body)
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].head)
}
@Test
fun trailingActionOutsideParagraphsAttachesToTail() {
val bubbles = parse("<p>你好</p><action>笑</action>")
assertEquals(1, bubbles.size)
assertEquals("你好", bubbles[0].body)
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].tail)
}
// ------------------------------------------------------------------
// 旁白依附:首部 → head,尾部 → tail,中间 → 上提 head
// ------------------------------------------------------------------
@Test
fun leadingNarrationAttachesToHead() {
val bubble = parse("<p><narration>其实很开心</narration>你好</p>").single()
assertEquals("你好", bubble.body)
assertEquals(listOf(ContentPart.Narration("其实很开心")), bubble.head)
assertTrue(bubble.tail.isEmpty())
}
@Test
fun trailingNarrationAttachesToTail() {
val bubble = parse("<p>你好<narration>其实很开心</narration></p>").single()
assertEquals("你好", bubble.body)
assertEquals(listOf(ContentPart.Narration("其实很开心")), bubble.tail)
assertTrue(bubble.head.isEmpty())
}
@Test
fun middleActionLiftsToHead() {
val bubble = parse("<p>开头<action>笑</action>结尾</p>").single()
assertEquals("开头结尾", bubble.body)
assertEquals(listOf(ContentPart.Action("笑")), bubble.head)
assertTrue(bubble.tail.isEmpty())
}
@Test
fun partsKeepOriginalOrder() {
val bubble = parse("<p>甲<action>乙</action>丙</p>").single()
assertEquals(
listOf(ContentPart.Text("甲"), ContentPart.Action("乙"), ContentPart.Text("丙")),
bubble.parts,
)
}
@Test
fun eachParagraphKeepsItsOwnNote() {
val bubbles = parse("<p><action>a</action>一</p><p><action>b</action>二</p>")
assertEquals(listOf("一", "二"), bubbles.map { it.body })
assertEquals(listOf(ContentPart.Action("a")), bubbles[0].head)
assertEquals(listOf(ContentPart.Action("b")), bubbles[1].head)
}
@Test
fun notesOnlyParagraphHasEmptyBody() {
val bubble = parse("<p><action>弯起眼睛笑了</action></p>").single()
assertEquals("", bubble.body)
assertEquals(listOf(ContentPart.Action("弯起眼睛笑了")), bubble.head)
assertFalse(bubble.isEmpty)
}
@Test
fun notesOnlyMessageWithoutParagraphTag() {
val bubble = parse("<action>轻轻拉住你的手</action>").single()
assertEquals("", bubble.body)
assertEquals(listOf(ContentPart.Action("轻轻拉住你的手")), bubble.head)
}
@Test
fun blankTextBeforeNoteStillRendersTheNote() {
val bubble = parse("<p> <action>笑</action></p>").single()
assertEquals("", bubble.body)
assertEquals(listOf(ContentPart.Action("笑")), bubble.head + bubble.tail)
}
// ------------------------------------------------------------------
// 漂移兼容:全角 / 半角星号
// ------------------------------------------------------------------
@Test
fun shortFullWidthStarWithoutKeywordBecomesAction() {
val bubble = parse("<p>你好*弯起眼睛*</p>").single()
assertEquals("你好", bubble.body)
assertEquals(listOf(ContentPart.Action("弯起眼睛")), bubble.tail)
}
@Test
fun fullWidthStarWithNarrationKeywordBecomesNarration() {
val bubble = parse("<p>*旁白:心想*</p>").single()
assertEquals("", bubble.body)
assertEquals(listOf(ContentPart.Narration("心想")), bubble.head)
}
@Test
fun fullWidthStarWithActionKeywordBecomesAction() {
val bubble = parse("<p>*动作:笑*</p>").single()
assertEquals(listOf(ContentPart.Action("笑")), bubble.head)
}
@Test
fun fullWidthStarWithInnerKeywordBecomesNarration() {
val bubble = parse("<p>*内心:有点开心*</p>").single()
assertEquals(listOf(ContentPart.Narration("有点开心")), bubble.head)
}
@Test
fun longFullWidthStarStaysText() {
val long = "a".repeat(41)
val bubble = parse("<p>你好*$long*</p>").single()
assertTrue(bubble.head.isEmpty())
assertTrue(bubble.tail.isEmpty())
assertEquals("你好*$long*", bubble.body)
}
@Test
fun multiLineFullWidthStarStaysText() {
val bubble = parse("<p>你好*弯起\n眼睛*</p>").single()
assertTrue(bubble.head.isEmpty())
assertTrue(bubble.tail.isEmpty())
assertTrue(bubble.body.contains("*弯起\n眼睛*"))
}
@Test
fun halfWidthStarWithKeywordBecomesAction() {
val bubble = parse("<p>你好*动作:笑*</p>").single()
assertEquals("你好", bubble.body)
assertEquals(listOf(ContentPart.Action("笑")), bubble.tail)
}
@Test
fun halfWidthStarWithNarrationKeywordBecomesNarration() {
val bubble = parse("<p>*内心:有点开心*你好</p>").single()
assertEquals("你好", bubble.body)
assertEquals(listOf(ContentPart.Narration("有点开心")), bubble.head)
}
@Test
fun halfWidthStarWithoutKeywordStaysMarkdownText() {
val bubble = parse("<p>这是*强调*文字</p>").single()
assertEquals("这是*强调*文字", bubble.body)
assertTrue(bubble.head.isEmpty())
assertTrue(bubble.tail.isEmpty())
}
@Test
fun halfWidthDoubleStarStaysMarkdownText() {
val bubble = parse("<p>这是**加粗**文字</p>").single()
assertEquals("这是**加粗**文字", bubble.body)
assertTrue(bubble.head.isEmpty())
assertTrue(bubble.tail.isEmpty())
}
// ------------------------------------------------------------------
// 畸形 / 未闭合:一律回落文本,绝不抛异常
// ------------------------------------------------------------------
@Test
fun unclosedActionFallsBackToText() {
val bubble = parse("<p>你好<action>笑</p>").single()
assertEquals("你好<action>笑", bubble.body)
assertTrue(bubble.parts.none { it is ContentPart.Action })
}
@Test
fun unclosedNarrationFallsBackToText() {
val bubble = parse("<p>你好<narration>心里暖暖的</p>").single()
assertEquals("你好<narration>心里暖暖的", bubble.body)
assertTrue(bubble.parts.none { it is ContentPart.Narration })
}
@Test
fun strayClosingTagStaysText() {
val bubble = parse("<p>你好</action></p>").single()
assertEquals("你好</action>", bubble.body)
assertTrue(bubble.head.isEmpty())
assertTrue(bubble.tail.isEmpty())
}
@Test
fun mismatchedTagsDoNotThrow() {
val bubbles = parse("<p><action>笑<narration>内心</action></p>")
assertEquals(1, bubbles.size)
assertTrue(bubbles[0].parts.any { it is ContentPart.Action })
}
// ------------------------------------------------------------------
// 流式增量:最终结果必须与一次性解析一致
// ------------------------------------------------------------------
private val sampleText = """
<p><action>轻轻拉住你的手</action>亲爱的,今天过得怎么样?♪</p>
<p>人家给你留了灯呢。</p>
<p>第一行
第二行</p>
<p>你好*弯起眼睛*</p>
<p>这是*强调*文字</p>
<p>*旁白:其实偷偷开心了一下*</p>
<p>晚安。</p>
""".trimIndent()
@Test
fun streamingCharByCharMatchesOneShot() {
val expected = parse(sampleText)
val parser = StreamingContentParser()
for (ch in sampleText) {
parser.onDelta(ch.toString())
}
assertEquals(expected, parser.finish())
}
@Test
fun streamingRandomChunksMatchOneShot() {
val expected = parse(sampleText)
val random = Random(20260908)
repeat(25) {
val parser = StreamingContentParser()
var index = 0
while (index < sampleText.length) {
val size = 1 + random.nextInt(7)
val end = minOf(index + size, sampleText.length)
parser.onDelta(sampleText.substring(index, end))
index = end
}
assertEquals(expected, parser.finish())
}
}
@Test
fun streamingStableInputEqualsOneShotBeforeFinish() {
val text = "<p>你好</p><p><action>笑</action>再见</p>"
val parser = StreamingContentParser()
assertEquals(parse(text), parser.onDelta(text))
}
@Test
fun streamingWithholdsPartialTag() {
val parser = StreamingContentParser()
val partial = parser.onDelta("<p>你好</p><act")
assertTrue(partial.none { it.body.contains("<act") })
assertTrue(partial.none { bubble -> bubble.parts.any { it.text.contains("<act") } })
parser.onDelta("ion>笑</action>")
assertEquals(parse("<p>你好</p><action>笑</action>"), parser.finish())
}
@Test
fun streamingWithholdsUnclosedParagraph() {
val parser = StreamingContentParser()
val first = parser.onDelta("<p>第一</p><p>第二")
assertEquals(listOf("第一"), first.map { it.body })
val second = parser.onDelta("</p>")
assertEquals(listOf("第一", "第二"), second.map { it.body })
}
@Test
fun streamingUnclosedActionFallsBackOnFinish() {
val parser = StreamingContentParser()
parser.onDelta("<p>你好<action>笑")
val done = parser.finish()
assertEquals(1, done.size)
// 未闭合的 <p> 与 <action> 一并回落为字面文本(与一次性解析一致)。
assertEquals("<p>你好<action>笑", done[0].body)
assertTrue(done[0].parts.none { it is ContentPart.Action })
}
@Test
fun streamingMalformedInputNeverThrows() {
val parser = StreamingContentParser()
val chunks = listOf(
"<", "p", ">", "你", "<act", "ion", ">", "笑",
"</", "act", "ion", ">", "</p", ">", "*", "动",
)
for (chunk in chunks) {
parser.onDelta(chunk)
}
assertTrue(parser.finish().isNotEmpty())
}
@Test
fun resetClearsBuffer() {
val parser = StreamingContentParser()
parser.onDelta("<p>旧</p>")
parser.reset()
assertTrue(parser.onDelta("").isEmpty())
assertEquals(listOf("新"), parser.onDelta("<p>新</p>").map { it.body })
}
}
+4
View File
@@ -0,0 +1,4 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+90
View File
@@ -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://<host>: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://<host>: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=<ms>&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`**:**默认展开**(方便观察思维;可折叠 + "总是收起"开关)。
- **旁白 `<action>/<narration>`**:灰斜体小字,**按出现位置依附气泡上/下**。
- **`mood`**:显示情绪标签(可关)。
- **渲染**:`<p>` 拆气泡;图片;忙态 `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 即登出、权限最小化、无悬浮球、不打日志敏感。
+207
View File
@@ -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 才解析 `<p>`」这条规则依赖它。
- **后端建议**:持久化时写入 `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=<seq 或 createdAt>` 补发,或提供 `GET /sessions/{id}/messages?after=<ms>` 正序增量接口。
- **当前绕行**:重连后 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` 可达性 | 待确认 | 低 |
+197
View File
@@ -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<SessionSummary>`(`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:<uuid>` 不能直接做文件名)。
- **内容**:最近 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<ContentPart>`。权威规则在 `configs/personas/cyrene.yaml`。
- `<p>…</p>` 每段一个气泡;无 `<p>` 时按空行 `\n\n` 回退切分,单个 `\n` 不切。
- `<action>` / `<narration>` → 灰斜体旁白,依附气泡:首部连续块在上、尾部连续块在下、中间块上提到 head。
- 兼容漂移:全角 `*…*`(短于 40 字符且无换行 → action);半角 `*…*` **仅当以 动作/旁白/内心/神态/轻声 开头**才算旁白,否则保持普通文本。
- **流式增量解析**:delta 会把标签切半(`<act` + `ion>`)→ 带缓冲的增量解析器,保留尾部未完成片段,只在能确定边界时产出;未闭合 `<p>` 不显示直到闭合。行为对齐 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 富渲染** | `<p>` 拆气泡 + 旁白 + 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 的具体验证清单与机器。
+95
View File
@@ -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://<host>: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:<uuid>),可新建/删除/恢复
│ └─ 聊天页:气泡流(<p>多气泡、<action>/<narration> 旁白灰斜体仅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=<ms>&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`(含 `<p>`/`<action>/<narration>` 需端侧解析)。
- **deviceId**:`voiceassist:myapp:v1`(design/16 §4.5 允许自定可读前缀),`POST /chat`/ws `chat` 携带 → 落库标注来源;主会话 `web` 与 Web 共享上下文(multiplatform 同步)。
## 4. 消息渲染(聊天页核心)
- 按 `mode` 决定形态:`chat` 用气泡;其它 `mode`(backthink/工具/审计等 refs)卡片化或折叠。
- **`chat` 文本解析**:把 content 按 **`<p>`** 拆成多条气泡(每段一条);`<action>…</action>`/`<narration>…</narration>` 解析为**灰斜体小字旁白**(依附气泡,不独立成气泡);`*…*`/`*…*` 兼容。**非 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` 映射、`<p>`/`<action>/<narration>` 解析器。
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` 契约随实现更新。
+15
View File
@@ -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
+47
View File
@@ -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" }
Binary file not shown.
+8
View File
@@ -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
Vendored Executable
+252
View File
@@ -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" "$@"
Vendored
+94
View File
@@ -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
+23
View File
@@ -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")