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")
|
||||||