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/ 收录设计文档、前后端协同事项与细化方案。
No files matched your search
@@ -0,0 +1,9 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.**
|
||||
|
||||
-keepclassmembers class **$$serializer {
|
||||
*** descriptor;
|
||||
}
|
||||
-keepclasseswithmembers class ** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#F8F8F8</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Cyrene</string>
|
||||
</resources>
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
}
|
||||
@@ -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 即登出、权限最小化、无悬浮球、不打日志敏感。
|
||||
@@ -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` 可达性 | 待确认 | 低 |
|
||||
@@ -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 的具体验证清单与机器。
|
||||
@@ -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` 契约随实现更新。
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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")
|
||||