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,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 })
|
||||
}
|
||||
}
|
||||