feat(enums): 接入 /meta/enums 权威枚举表,删掉客户端硬编码映射

客户端此前自带 platform / mood / mode 三张硬编码标签表,会与服务端漂移——
实测已经漂了:客户端把 `web` 显示成「Web」,权威表里是「网页」。

- data:MetaApi(GET /api/v1/meta/enums)+ MetaDto + MetaRepository,
  经 ApiClientFactory 创建以复用既有 token / 401 处理
- domain/enums/EnumCatalog.kt:标签查找器,**不含任何硬编码兜底表**;
  不在表内的取值一律原样返回(api-client §1.1 的未知值回落红线);
  platformLabelOrNull 供 deviceId 前缀这类「认识/不认识要走不同回落」的调用方用;
  空 id 或空 label 一律不入表(否则角标会显示空白,或查空串凭空借到别人的标签)
- core/prefs/EnumPreferences.kt:last-good 快照,baseUrl+username 指纹闸门,
  换服务器/账号即作废,登出清空
- EnumCatalogViewModel + LocalEnumCatalog:在 CyreneRoot 注入(与 LocalAppPrefs 同套路),
  聊天角标与管理页共读同一份
- 删除 platformLabels / personaModeLabels / moodLabels;MessageSource 与 AdminFormat
  改为查表,并保留各自原有的原样回落

为什么不留硬编码兜底表:没有登录就看不到任何消息,而权威表在登录前后即会拉取,
所以 last-good 快照几乎总是存在;留着硬编码表等于把「漂移」重新引进来,而且是无声的——
旧标签和新鲜标签长得一模一样。

不转换的:trace kind/status、memory scope、thinking.mode、WS session_busy 的 mode 集合——
它们都不在 /meta/enums 内,且原本就已按原样回落。

测试:115 全绿(88 → +27:MetaDtoRealPayload 8 / EnumCatalog 7 / EnumPrefs 3 /
MessageSource 8,AdminFormatTest 改写并 +1),其中 /meta/enums 与登录响应一样
用真实抓取的响应体做夹具。
This commit is contained in:
AskaEth committed 2026-10-04 13:19:34 +08:00
1 parent 9f054aad28
commit 09a3222b7b
17 files changed
+693 -48

No files matched your search

@@ -4,12 +4,15 @@ import android.content.Context
import top.yeij.cyrene.core.auth.AuthManager import top.yeij.cyrene.core.auth.AuthManager
import top.yeij.cyrene.core.auth.SessionStore import top.yeij.cyrene.core.auth.SessionStore
import top.yeij.cyrene.core.prefs.AppSettings import top.yeij.cyrene.core.prefs.AppSettings
import top.yeij.cyrene.core.prefs.EnumPreferences
import top.yeij.cyrene.core.prefs.SessionPreferences import top.yeij.cyrene.core.prefs.SessionPreferences
import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.AdminApi
import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.MetaApi
import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.AdminRepository
import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.MetaRepository
import top.yeij.cyrene.data.repository.SessionRepository import top.yeij.cyrene.data.repository.SessionRepository
import top.yeij.cyrene.data.ws.ChatSocket import top.yeij.cyrene.data.ws.ChatSocket
@@ -18,6 +21,7 @@ class AppContainer(context: Context) {
val sessionStore: SessionStore = SessionStore(context.applicationContext) val sessionStore: SessionStore = SessionStore(context.applicationContext)
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext) val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
val enumPreferences: EnumPreferences = EnumPreferences(context.applicationContext)
val appSettings: AppSettings = AppSettings(context.applicationContext) val appSettings: AppSettings = AppSettings(context.applicationContext)
val authManager: AuthManager = AuthManager(sessionStore) val authManager: AuthManager = AuthManager(sessionStore)
@@ -67,6 +71,28 @@ class AppContainer(context: Context) {
val adminRepository: AdminRepository get() = AdminRepository(adminApi) val adminRepository: AdminRepository get() = AdminRepository(adminApi)
private var cachedMetaApi: MetaApi? = null
private var cachedMetaBaseUrl: String? = null
/** 枚举权威表客户端;同样与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val metaApi: MetaApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedMetaApi
if (cached != null && cachedMetaBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = MetaApi::class.java,
tokenProvider = { sessionStore.load().token },
onUnauthorized = { authManager.onSessionExpired() },
)
cachedMetaBaseUrl = baseUrl
cachedMetaApi = created
return created
}
val metaRepository: MetaRepository get() = MetaRepository(metaApi)
fun createChatSocket(): ChatSocket = ChatSocket( fun createChatSocket(): ChatSocket = ChatSocket(
baseUrlProvider = { authManager.currentBaseUrl }, baseUrlProvider = { authManager.currentBaseUrl },
tokenProvider = { sessionStore.load().token }, tokenProvider = { sessionStore.load().token },
@@ -11,19 +11,27 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import top.yeij.cyrene.core.prefs.AppPrefs import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.LocalAppPrefs import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yeij.cyrene.core.prefs.ThemeMode import top.yeij.cyrene.core.prefs.ThemeMode
import top.yeij.cyrene.core.theme.CyreneTheme import top.yeij.cyrene.core.theme.CyreneTheme
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yukonga.miuix.kmp.theme.ColorSchemeMode import top.yukonga.miuix.kmp.theme.ColorSchemeMode
/** /**
* 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] 下发给渲染层, * 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] / [LocalEnumCatalog] 下发给渲染层,
* 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。 * 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。
*/ */
@Composable @Composable
fun CyreneRoot(container: AppContainer) { fun CyreneRoot(container: AppContainer) {
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs()) val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
val enumCatalogViewModel: EnumCatalogViewModel = viewModel(
factory = viewModelFactory { initializer { EnumCatalogViewModel(container) } },
)
val catalog by enumCatalogViewModel.catalog.collectAsState()
val darkTheme = when (prefs.themeMode) { val darkTheme = when (prefs.themeMode) {
ThemeMode.System -> isSystemInDarkTheme() ThemeMode.System -> isSystemInDarkTheme()
ThemeMode.Light -> false ThemeMode.Light -> false
@@ -41,7 +49,10 @@ fun CyreneRoot(container: AppContainer) {
} }
} }
CompositionLocalProvider(LocalAppPrefs provides prefs) { CompositionLocalProvider(
LocalAppPrefs provides prefs,
LocalEnumCatalog provides catalog,
) {
CyreneTheme(mode = prefs.themeMode.toColorSchemeMode()) { CyreneTheme(mode = prefs.themeMode.toColorSchemeMode()) {
CyreneApp(container) CyreneApp(container)
} }
@@ -0,0 +1,53 @@
package top.yeij.cyrene.app
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.domain.enums.EnumCatalog
/**
* 枚举权威表(api-client §1.1)的应用级持有者:先用本地快照渲染,再拉一次权威表。
*
* - 登录 / 静默重登后拉取;**拉取失败保持现状**(快照或原样 id),绝不因此清空角标;
* - 快照按 `baseUrl + username` 指纹认领:换服务器/账号即作废;
* - 登出时落盘快照与内存标签一并清掉(此时界面只剩登录页,不会闪空角标)。
*/
class EnumCatalogViewModel(private val container: AppContainer) : ViewModel() {
private val _catalog = MutableStateFlow(EnumCatalog.Empty)
val catalog: StateFlow<EnumCatalog> = _catalog.asStateFlow()
init {
viewModelScope.launch {
container.authManager.state.collect { state ->
if (state == SessionState.LoggedOut) {
container.enumPreferences.clear()
_catalog.value = EnumCatalog.Empty
return@collect
}
val fingerprint = fingerprint()
// 先用快照渲染(没有就原样显示 id),拉取不阻塞首帧
val snapshot = container.enumPreferences.data.first()
_catalog.value = EnumCatalog.of(snapshot.tableFor(fingerprint))
if (state is SessionState.LoggedIn) refresh(fingerprint)
}
}
}
private fun refresh(fingerprint: String) {
viewModelScope.launch {
container.metaRepository.enums().onSuccess { table ->
container.enumPreferences.save(table, fingerprint)
_catalog.value = EnumCatalog.of(table)
}
}
}
private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
}
@@ -0,0 +1,63 @@
package top.yeij.cyrene.core.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.dto.EnumTableDto
private val Context.enumDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_enums")
/**
* 枚举权威表的本地回退快照(§1.1)。
*
* @param table 上次成功拉取的权威表;拉取失败时先用它渲染(绝不因此清空角标)
* @param fingerprint `baseUrl + username`:换服务器/账号后快照作废
*/
data class EnumPrefs(
val table: EnumTableDto? = null,
val fingerprint: String = "",
) {
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的标签拿来用。 */
fun tableFor(fingerprint: String): EnumTableDto? = table?.takeIf { this.fingerprint == fingerprint }
}
class EnumPreferences(context: Context) {
private val json = ApiClientFactory.json
private val store: DataStore<Preferences> = context.applicationContext.enumDataStore
val data: Flow<EnumPrefs> = store.data.map { prefs ->
EnumPrefs(
table = prefs[KEY_TABLE].decodeTable(),
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
)
}
suspend fun save(table: EnumTableDto, fingerprint: String) {
store.edit { prefs ->
prefs[KEY_TABLE] = json.encodeToString(EnumTableDto.serializer(), table)
prefs[KEY_FINGERPRINT] = fingerprint
}
}
/** 登出 / 切换服务器时清空。 */
suspend fun clear() {
store.edit { it.clear() }
}
private fun String?.decodeTable(): EnumTableDto? = this
?.let { runCatching { json.decodeFromString(EnumTableDto.serializer(), it) }.getOrNull() }
private companion object {
val KEY_TABLE = stringPreferencesKey("enum_table")
val KEY_FINGERPRINT = stringPreferencesKey("enum_fingerprint")
}
}
@@ -0,0 +1,16 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.EnumTableDto
import retrofit2.Response
import retrofit2.http.GET
/**
* 共享枚举权威表端点(api-client §1.1 / api-reference §3.7)。
* 任意已认证角色可读(与 `GET /sessions` 同级),无 token 回 `401`;
* 纯内存投影,没有其它错误码。按本工程惯例单独成接口。
*/
interface MetaApi {
@GET("api/v1/meta/enums")
suspend fun enums(): Response<ApiEnvelope<EnumTableDto>>
}
@@ -0,0 +1,29 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `GET /api/v1/meta/enums` 表内一项(api-client §1.1)。
* [id] 是**唯一可比对**的值(契约 id 为小写、大小写敏感);[label] 只是显示默认值,
* 自带 i18n 的客户端可以覆盖。
*/
@Serializable
data class EnumItemDto(
val id: String = "",
val label: String = "",
)
/**
* `platform` / `mood` / `mode` 的共享枚举权威表——客户端**不必硬编码**即可拿到稳定映射。
*
* 字段全部带默认值:只下发部分表(或 `null`)时仍能解码,缺失的那一类按「表内无此项」
* 走原样回落,而不是让整次解码失败。
* [unknown] 是机器可读的未知值回落约定,当前恒为 `"display-raw"`(原样显示,见 §1.1 红线)。
*/
@Serializable
data class EnumTableDto(
val platforms: List<EnumItemDto> = emptyList(),
val moods: List<EnumItemDto> = emptyList(),
val modes: List<EnumItemDto> = emptyList(),
val unknown: String = "",
)
@@ -0,0 +1,22 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import top.yeij.cyrene.data.api.MetaApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.EnumTableDto
/**
* 枚举权威表取数层(api-client §1.1)。
* 仓库不持有持久化状态:调用方失败时保留上次快照(见 [top.yeij.cyrene.core.prefs.EnumPreferences])。
*/
class MetaRepository(
private val api: MetaApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun enums(): Result<EnumTableDto> = withContext(dispatcher) {
apiCall { api.enums() }
}
}
@@ -2,6 +2,7 @@ package top.yeij.cyrene.domain.admin
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import top.yeij.cyrene.domain.enums.EnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.parse import top.yeij.cyrene.domain.parse.parse
import java.math.BigDecimal import java.math.BigDecimal
@@ -72,32 +73,12 @@ private val traceStatusLabels = mapOf(
"error" to "失败", "error" to "失败",
) )
private val personaModeLabels = mapOf(
"chat" to "对话",
"code" to "代码",
"home" to "家居",
"work" to "工作",
"docs" to "文档",
"voice" to "语音",
"vision" to "视觉",
"agents" to "智能体",
"settings" to "设置",
)
private val memoryScopeLabels = mapOf( private val memoryScopeLabels = mapOf(
"user" to "用户", "user" to "用户",
"persona" to "人格", "persona" to "人格",
"project" to "项目", "project" to "项目",
) )
private val moodLabels = mapOf(
"calm" to "平静",
"happy" to "开心",
"excited" to "兴奋",
"sad" to "低落",
"angry" to "生气",
)
/** /**
* 未知取值回落约定(api-client §1 红线):不在表中的取值**原样返回**—— * 未知取值回落约定(api-client §1 红线):不在表中的取值**原样返回**——
* 不丢弃、不报错、不替换占位符。服务端新增取值属非破坏性变更。 * 不丢弃、不报错、不替换占位符。服务端新增取值属非破坏性变更。
@@ -106,11 +87,16 @@ fun traceKindLabel(raw: String): String = traceKindLabels[raw] ?: raw
fun traceStatusLabel(raw: String): String = traceStatusLabels[raw] ?: raw fun traceStatusLabel(raw: String): String = traceStatusLabels[raw] ?: raw
fun personaModeLabel(raw: String): String = personaModeLabels[raw] ?: raw /**
* `mode` / `mood` 的标签来自 `/meta/enums` 的权威表([catalog]),客户端不再硬编码——
* 表里没有的取值同样原样返回([EnumCatalog] 负责该回落)。
* `trace` / `memory` 那几类**不在**该端点的表内,各自保留本地映射(见上)。
*/
fun personaModeLabel(raw: String, catalog: EnumCatalog): String = catalog.modeLabel(raw)
fun memoryScopeLabel(raw: String): String = memoryScopeLabels[raw] ?: raw fun memoryScopeLabel(raw: String): String = memoryScopeLabels[raw] ?: raw
fun moodLabel(raw: String): String = moodLabels[raw] ?: raw fun moodLabel(raw: String, catalog: EnumCatalog): String = catalog.moodLabel(raw)
/** 三态展示:null(未配置)与 true/false 是**三种不同**状态,不能合并。 */ /** 三态展示:null(未配置)与 true/false 是**三种不同**状态,不能合并。 */
fun triStateLabel(value: Boolean?): String = when (value) { fun triStateLabel(value: Boolean?): String = when (value) {
@@ -0,0 +1,63 @@
package top.yeij.cyrene.domain.enums
import androidx.compose.runtime.staticCompositionLocalOf
import top.yeij.cyrene.data.api.dto.EnumItemDto
import top.yeij.cyrene.data.api.dto.EnumTableDto
/**
* 枚举标签查找器(api-client §1.1):`/meta/enums` 抓到什么就查什么。
*
* **未知取值回落(红线)**:不在表内的取值一律**原样返回**——不丢弃、不报错、不替换占位符、
* 不猜测;服务端新增取值属非破坏性变更,不得因此清空角标或中断渲染。
*
* 因此这里**没有任何硬编码兜底表**:宁可显示原始 id,也不能让客户端自建的表与服务端漂移。
* 还没拿到表(未登录 / 首次拉取失败且无快照)时用 [Empty],一切原样显示。
*
* 判定只比对 `id`(大小写敏感,契约 id 为小写);`label` 仅作显示默认值。
*/
class EnumCatalog private constructor(
private val platforms: Map<String, String>,
private val moods: Map<String, String>,
private val modes: Map<String, String>,
) {
/** 表内 → 默认标签;不在表内 → 原始值。 */
fun platformLabel(id: String): String = platforms[id] ?: id
fun moodLabel(id: String): String = moods[id] ?: id
fun modeLabel(id: String): String = modes[id] ?: id
/**
* 表内 → 默认标签;不在表内 → `null`。
* 给「认识」与「不认识」需要走**不同**回落的调用方用(如 `deviceId` 前缀:
* 认识的平台名取友好标签,不认识的原样显示整串 `deviceId`,而不是只留前缀)。
*/
fun platformLabelOrNull(id: String): String? = platforms[id]
companion object {
/** 尚无权威表:一切原样显示。 */
val Empty = EnumCatalog(emptyMap(), emptyMap(), emptyMap())
fun of(table: EnumTableDto?): EnumCatalog = if (table == null) {
Empty
} else {
EnumCatalog(
platforms = table.platforms.toLabels(),
moods = table.moods.toLabels(),
modes = table.modes.toLabels(),
)
}
/**
* 空 id 不入表:否则查空串会拿到别人的标签,等于凭空造出一个标签。
* 空 label 同样不入表:查得到却没有标签只会把角标显示成空白,不如原样显示 id。
*/
private fun List<EnumItemDto>.toLabels(): Map<String, String> = mapNotNull { item ->
item.takeIf { it.id.isNotBlank() && it.label.isNotBlank() }?.let { it.id to it.label }
}.toMap()
}
}
/** 全局注入枚举标签查找器,供聊天角标 / 管理页等深层组件读取(与 `LocalAppPrefs` 同套路)。 */
val LocalEnumCatalog = staticCompositionLocalOf { EnumCatalog.Empty }
@@ -25,6 +25,7 @@ import top.yeij.cyrene.domain.admin.moodLabel
import top.yeij.cyrene.domain.admin.orDash import top.yeij.cyrene.domain.admin.orDash
import top.yeij.cyrene.domain.admin.personaModeLabel import top.yeij.cyrene.domain.admin.personaModeLabel
import top.yeij.cyrene.domain.admin.triStateLabel import top.yeij.cyrene.domain.admin.triStateLabel
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.ui.common.PreferenceCard import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -62,6 +63,7 @@ fun AdminPersonaScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () ->
@Composable @Composable
private fun PersonaCard(persona: PersonaSummaryDto) { private fun PersonaCard(persona: PersonaSummaryDto) {
val catalog = LocalEnumCatalog.current
PreferenceCard { PreferenceCard {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -76,11 +78,14 @@ private fun PersonaCard(persona: PersonaSummaryDto) {
} }
PersonaDetail("id", persona.id) PersonaDetail("id", persona.id)
PersonaDetail("character", persona.character.ifBlank { "-" }) PersonaDetail("character", persona.character.ifBlank { "-" })
// mode 是开放集合:未知取值原样显示。 // mode 是开放集合:权威表里没有的取值原样显示。
PersonaDetail("mode", personaModeLabel(persona.mode).ifBlank { "-" }) PersonaDetail("mode", personaModeLabel(persona.mode, catalog).ifBlank { "-" })
PersonaDetail("记忆域", persona.memoryNamespace.ifBlank { "-" }) PersonaDetail("记忆域", persona.memoryNamespace.ifBlank { "-" })
// mood 为空表示「该角色暂无被跟踪的情绪」,不伪造默认值。 // mood 为空表示「该角色暂无被跟踪的情绪」,不伪造默认值。
PersonaDetail("情绪", persona.mood?.takeIf { it.isNotBlank() }?.let(::moodLabel) ?: "暂无") PersonaDetail(
"情绪",
persona.mood?.takeIf { it.isNotBlank() }?.let { moodLabel(it, catalog) } ?: "暂无",
)
PersonaDetail("思考", thinkingLabel(persona.thinking)) PersonaDetail("思考", thinkingLabel(persona.thinking))
persona.avatar?.let { PersonaDetail("avatar", it) } persona.avatar?.let { PersonaDetail("avatar", it) }
} }
@@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import top.yeij.cyrene.core.prefs.LocalAppPrefs import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse import top.yeij.cyrene.domain.parse.parse
@@ -43,13 +44,16 @@ private const val CURSOR = "▍"
@Composable @Composable
fun MessageBubble(message: HistoryMessageDto) { fun MessageBubble(message: HistoryMessageDto) {
val isUser = message.role == "user" val isUser = message.role == "user"
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
// 表只能给「id → 标签」,说不出哪个 mode 是「按 markup 切气泡的聊天式渲染」,
// 所以这条字面量刻意保留为客户端渲染契约,不表驱动。
val isChat = message.mode.isNullOrBlank() || message.mode == "chat" val isChat = message.mode.isNullOrBlank() || message.mode == "chat"
val bubbles = remember(message.content, isChat) { val bubbles = remember(message.content, isChat) {
val parsed = if (isChat) parse(message.content).filterNot { it.isEmpty } else emptyList() val parsed = if (isChat) parse(message.content).filterNot { it.isEmpty } else emptyList()
parsed.ifEmpty { listOf(plainBubble(message.content)) } parsed.ifEmpty { listOf(plainBubble(message.content)) }
} }
MessageGroup(isUser = isUser, footer = messageSourceLabel(message)) { MessageGroup(isUser = isUser, footer = messageSourceLabel(message, LocalEnumCatalog.current)) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) } message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) } message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) }
bubbles.forEachIndexed { index, bubble -> bubbles.forEachIndexed { index, bubble ->
@@ -1,42 +1,39 @@
package top.yeij.cyrene.ui.chat package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.enums.EnumCatalog
private val platformLabels = mapOf(
"web" to "Web",
"kook" to "KOOK",
"weixin-ilink" to "微信",
"qq-obv11" to "QQ",
"telegram" to "Telegram",
"webhook" to "Webhook",
)
/** /**
* 来源角标(S1.3):每条消息默认显示来源。 * 来源角标(S1.3):每条消息默认显示来源。
* - 主人发的 → 「来自 X」(从哪一端发出) * - 主人发的 → 「来自 X」(从哪一端发出)
* - 昔涟发的 → 「发送至 X」(回到哪一端) * - 昔涟发的 → 「发送至 X」(回到哪一端)
* 优先用 `deviceId` 区分具体端,再回落到 `platform`;两者都没有则不显示。 * 优先用 `deviceId` 区分具体端,再回落到 `platform`;两者都没有则不显示。
*
* 平台名到标签的映射来自 `/meta/enums` 的权威表([catalog]),**不含任何硬编码表**:
* 表里没有的取值原样显示(红线),未登录 / 尚无快照时全部原样。
*/ */
fun messageSourceLabel(message: HistoryMessageDto): String? { fun messageSourceLabel(message: HistoryMessageDto, catalog: EnumCatalog): String? {
val name = sourceName(message) ?: return null val name = sourceName(message, catalog) ?: return null
val verb = if (message.role == "assistant") "发送至" else "来自" val verb = if (message.role == "assistant") "发送至" else "来自"
return "$verb $name" return "$verb $name"
} }
private fun sourceName(message: HistoryMessageDto): String? { private fun sourceName(message: HistoryMessageDto, catalog: EnumCatalog): String? {
val deviceId = message.deviceId val deviceId = message.deviceId
if (!deviceId.isNullOrBlank()) { if (!deviceId.isNullOrBlank()) {
// 下面两条是 device 粒度、与平台表无关的端标识,不参与权威表映射。
if (deviceId.startsWith("voiceassist")) return "安卓" if (deviceId.startsWith("voiceassist")) return "安卓"
if (deviceId.startsWith("web:")) { if (deviceId.startsWith("web:")) {
val browser = deviceId.removePrefix("web:").substringBefore(':') val browser = deviceId.removePrefix("web:").substringBefore(':')
return "Web($browser)" return "Web($browser)"
} }
// IM 端 deviceId 形如 <平台名>:<配置id>:<uid>,只取平台名做友好显示 // IM 端 deviceId 形如 <平台名>:<配置id>:<uid>,只取平台名做友好显示;
platformLabels[deviceId.substringBefore(':')]?.let { return it } // 平台名不在表里(服务端新增渠道)则整串原样显示,不丢信息也不猜。
return deviceId val platform = deviceId.substringBefore(':')
return catalog.platformLabelOrNull(platform) ?: deviceId
} }
val platform = message.platform val platform = message.platform
if (!platform.isNullOrBlank()) return platformLabels[platform] ?: platform if (!platform.isNullOrBlank()) return catalog.platformLabel(platform)
return null return null
} }
@@ -0,0 +1,38 @@
package top.yeij.cyrene.core.prefs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import top.yeij.cyrene.data.api.dto.EnumItemDto
import top.yeij.cyrene.data.api.dto.EnumTableDto
/**
* 快照的指纹闸门:`baseUrl + username` 变了,上一份权威表就作废——
* 绝不把别的服务器/账号的标签拿来用(宁可原样显示 id)。
*/
class EnumPrefsTest {
private val table = EnumTableDto(platforms = listOf(EnumItemDto("web", "网页")))
private val fingerprint = "http://host:10942/|testuser"
@Test
fun `a matching fingerprint yields the snapshot`() {
assertEquals(table, EnumPrefs(table, fingerprint).tableFor(fingerprint))
}
@Test
fun `another account or another server invalidates the snapshot`() {
val prefs = EnumPrefs(table, fingerprint)
assertNull(prefs.tableFor("http://host:10942/|someone-else"))
assertNull(prefs.tableFor("http://other-host:10942/|testuser"))
assertNull(prefs.tableFor("|"))
}
@Test
fun `no snapshot yet stays null even for the matching fingerprint`() {
assertNull(EnumPrefs(table = null, fingerprint = fingerprint).tableFor(fingerprint))
assertNull(EnumPrefs().tableFor(""))
}
}
@@ -0,0 +1,128 @@
package top.yeij.cyrene.data.api.dto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiEnvelope
import top.yeij.cyrene.domain.enums.EnumCatalog
/**
* 契约钉死测试:用的是**线上后端实际抓下来的** `GET /api/v1/meta/enums` 响应体,
* 逐字节照抄(含 `unknown`)。
*
* 解码器用 [ApiClientFactory.json] —— 与生产同一份 `Json` 配置,所以这里过不了的形状线上也过不了。
*
* 关键漂移点:客户端旧硬编码表把 `web` 写成 `Web`,权威表给的是 **`网页`**;
* 本测试把 6/5/9 三张表的 id 与 label 全钉死,任一侧漂移即失败。
*/
class MetaDtoRealPayloadTest {
private val json = ApiClientFactory.json
private val realEnums = """
{"data":{"platforms":[{"id":"web","label":"网页"},{"id":"kook","label":"KOOK"},{"id":"qq-obv11","label":"QQ"},{"id":"weixin-ilink","label":"微信"},{"id":"telegram","label":"Telegram"},{"id":"webhook","label":"Webhook"}],"moods":[{"id":"calm","label":"平静"},{"id":"happy","label":"开心"},{"id":"excited","label":"兴奋"},{"id":"sad","label":"低落"},{"id":"angry","label":"生气"}],"modes":[{"id":"chat","label":"对话"},{"id":"code","label":"代码"},{"id":"home","label":"家居"},{"id":"work","label":"工作"},{"id":"docs","label":"文档"},{"id":"voice","label":"语音"},{"id":"vision","label":"视觉"},{"id":"agents","label":"智能体"},{"id":"settings","label":"设置"}],"unknown":"display-raw"}}
""".trimIndent()
private fun decode(body: String): EnumTableDto =
json.decodeFromString(ApiEnvelope.serializer(EnumTableDto.serializer()), body).data!!
@Test
fun `real platforms table decodes with authoritative ids and labels`() {
val platforms = decode(realEnums).platforms
assertEquals(6, platforms.size)
assertEquals(
listOf("web", "kook", "qq-obv11", "weixin-ilink", "telegram", "webhook"),
platforms.map { it.id },
)
assertEquals(
listOf("网页", "KOOK", "QQ", "微信", "Telegram", "Webhook"),
platforms.map { it.label },
)
// 客户端旧硬编码表写的是 `Web`——这条断言就是那处漂移的钉子。
assertEquals("网页", platforms.first { it.id == "web" }.label)
}
@Test
fun `real moods table has five tracked moods`() {
val moods = decode(realEnums).moods
assertEquals(5, moods.size)
assertEquals(listOf("calm", "happy", "excited", "sad", "angry"), moods.map { it.id })
assertEquals(listOf("平静", "开心", "兴奋", "低落", "生气"), moods.map { it.label })
}
@Test
fun `real modes table has nine modes`() {
val modes = decode(realEnums).modes
assertEquals(9, modes.size)
assertEquals(
listOf("chat", "code", "home", "work", "docs", "voice", "vision", "agents", "settings"),
modes.map { it.id },
)
assertEquals(
listOf("对话", "代码", "家居", "工作", "文档", "语音", "视觉", "智能体", "设置"),
modes.map { it.label },
)
}
@Test
fun `real payload carries the machine readable raw fallback convention`() {
val table = decode(realEnums)
assertEquals("display-raw", table.unknown)
// 契约 id 一律小写:判定大小写敏感,客户端不得自行归一化。
assertTrue(
(table.platforms + table.moods + table.modes).all { it.id == it.id.lowercase() },
)
}
@Test
fun `catalog built from the real payload agrees with the backend table`() {
val catalog = EnumCatalog.of(decode(realEnums))
assertEquals("网页", catalog.platformLabel("web"))
assertEquals("微信", catalog.platformLabel("weixin-ilink"))
assertEquals("生气", catalog.moodLabel("angry"))
assertEquals("智能体", catalog.modeLabel("agents"))
// 服务端新增取值:非破坏性,客户端原样显示。
assertEquals("mastodon", catalog.platformLabel("mastodon"))
assertEquals("euphoric", catalog.moodLabel("euphoric"))
assertEquals("telepathy", catalog.modeLabel("telepathy"))
}
@Test
fun `partial payload still decodes and missing tables fall back to raw`() {
val table = decode("""{"data":{"platforms":[{"id":"web","label":"网页"}]}}""")
assertEquals(listOf(EnumItemDto("web", "网页")), table.platforms)
// 缺的三张表 / 约定用默认值兜住,而不是让整次解码失败。
assertEquals(emptyList<EnumItemDto>(), table.moods)
assertEquals(emptyList<EnumItemDto>(), table.modes)
assertEquals("", table.unknown)
val catalog = EnumCatalog.of(table)
assertEquals("网页", catalog.platformLabel("web"))
assertEquals("calm", catalog.moodLabel("calm"))
assertEquals("chat", catalog.modeLabel("chat"))
}
@Test
fun `explicit nulls in the payload are coerced to the defaults`() {
val table = decode("""{"data":{"platforms":null,"moods":[],"modes":[],"unknown":null}}""")
assertEquals(emptyList<EnumItemDto>(), table.platforms)
assertEquals("", table.unknown)
}
@Test
fun `item without a label decodes to an empty label, which the catalog treats as absent`() {
val table = decode("""{"data":{"platforms":[{"id":"future"}]}}""")
assertEquals(EnumItemDto("future", ""), table.platforms.single())
// label 缺失时不拿 id 充数、也不显示空角标:整项按「不在表内」处理,原样显示 id。
assertEquals("future", EnumCatalog.of(table).platformLabel("future"))
}
}
@@ -6,6 +6,9 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import top.yeij.cyrene.data.api.dto.EnumItemDto
import top.yeij.cyrene.data.api.dto.EnumTableDto
import top.yeij.cyrene.domain.enums.EnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart import top.yeij.cyrene.domain.parse.ContentPart
/** /**
@@ -18,6 +21,16 @@ class AdminFormatTest {
private val timestamp = Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}""") private val timestamp = Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}""")
/** `/meta/enums` 的权威表由调用方注入:这里只登记各表的一部分,用来验证「查表」而非「本地表」。 */
private val enumCatalog = EnumCatalog.of(
EnumTableDto(
platforms = listOf(EnumItemDto("web", "网页")),
moods = listOf(EnumItemDto("calm", "平静")),
modes = listOf(EnumItemDto("chat", "对话")),
unknown = "display-raw",
),
)
@Test @Test
fun `offset and zulu spellings of the same instant agree`() { fun `offset and zulu spellings of the same instant agree`() {
assertEquals( assertEquals(
@@ -91,12 +104,23 @@ class AdminFormatTest {
assertEquals("quantum_hop", traceKindLabel("quantum_hop")) assertEquals("quantum_hop", traceKindLabel("quantum_hop"))
assertEquals("成功", traceStatusLabel("ok")) assertEquals("成功", traceStatusLabel("ok"))
assertEquals("degraded", traceStatusLabel("degraded")) assertEquals("degraded", traceStatusLabel("degraded"))
assertEquals("对话", personaModeLabel("chat")) assertEquals("对话", personaModeLabel("chat", enumCatalog))
assertEquals("telepathy", personaModeLabel("telepathy")) assertEquals("telepathy", personaModeLabel("telepathy", enumCatalog))
assertEquals("人格", memoryScopeLabel("persona")) assertEquals("人格", memoryScopeLabel("persona"))
assertEquals("shared", memoryScopeLabel("shared")) assertEquals("shared", memoryScopeLabel("shared"))
assertEquals("平静", moodLabel("calm")) assertEquals("平静", moodLabel("calm", enumCatalog))
assertEquals("euphoric", moodLabel("euphoric")) assertEquals("euphoric", moodLabel("euphoric", enumCatalog))
}
@Test
fun `mode and mood are looked up in the injected table, not in a client-side copy`() {
// 注入表里只有 chat / calm:同为真实取值的 code / happy 没有表项,
// 因此必须原样显示——本地硬编码表会翻成「代码」「开心」,这条断言就是那处漂移的钉子。
assertEquals("code", personaModeLabel("code", enumCatalog))
assertEquals("happy", moodLabel("happy", enumCatalog))
// 没有表(未登录 / 尚无快照)时同样原样显示,不回落任何客户端硬编码。
assertEquals("chat", personaModeLabel("chat", EnumCatalog.Empty))
assertEquals("calm", moodLabel("calm", EnumCatalog.Empty))
} }
@Test @Test
@@ -0,0 +1,89 @@
package top.yeij.cyrene.domain.enums
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import top.yeij.cyrene.data.api.dto.EnumItemDto
import top.yeij.cyrene.data.api.dto.EnumTableDto
/**
* 标签查找的未知值回落(api-client §1.1 红线):表里有就翻译,表里没有就**原样返回**——
* 不丢弃、不报错、不替换占位符、不猜测。
*/
class EnumCatalogTest {
private val catalog = EnumCatalog.of(
EnumTableDto(
platforms = listOf(EnumItemDto("web", "网页"), EnumItemDto("telegram", "Telegram")),
moods = listOf(EnumItemDto("calm", "平静")),
modes = listOf(EnumItemDto("chat", "对话")),
unknown = "display-raw",
),
)
@Test
fun `known ids resolve to the authoritative labels`() {
assertEquals("网页", catalog.platformLabel("web"))
assertEquals("Telegram", catalog.platformLabel("telegram"))
assertEquals("平静", catalog.moodLabel("calm"))
assertEquals("对话", catalog.modeLabel("chat"))
}
@Test
fun `unknown ids are returned verbatim, never dropped or replaced`() {
assertEquals("mastodon", catalog.platformLabel("mastodon"))
assertEquals("euphoric", catalog.moodLabel("euphoric"))
assertEquals("telepathy", catalog.modeLabel("telepathy"))
// 判定只比对 id 且大小写敏感:`WEB` 不是表里的 `web`,不得归一化后当命中。
assertEquals("WEB", catalog.platformLabel("WEB"))
}
@Test
fun `a blank value is not looked up and stays blank`() {
assertEquals("", catalog.platformLabel(""))
assertEquals("", catalog.moodLabel(""))
assertEquals("", catalog.modeLabel(""))
}
@Test
fun `empty catalog degrades everything to raw ids`() {
assertEquals("web", EnumCatalog.Empty.platformLabel("web"))
assertEquals("calm", EnumCatalog.Empty.moodLabel("calm"))
assertEquals("chat", EnumCatalog.Empty.modeLabel("chat"))
assertEquals("web", EnumCatalog.of(null).platformLabel("web"))
}
@Test
fun `tables not present in a partial payload fall back to raw ids`() {
val platformsOnly = EnumCatalog.of(EnumTableDto(platforms = listOf(EnumItemDto("web", "网页"))))
assertEquals("网页", platformsOnly.platformLabel("web"))
assertEquals("calm", platformsOnly.moodLabel("calm"))
assertEquals("chat", platformsOnly.modeLabel("chat"))
}
@Test
fun `items with a blank id or blank label are not registered`() {
val table = EnumCatalog.of(
EnumTableDto(
platforms = listOf(
EnumItemDto("", "空 id"),
EnumItemDto("web", ""),
EnumItemDto("kook", "KOOK"),
),
),
)
// 空 id 不得让空查询命中;空 label 不得把角标显示成空白。
assertEquals("", table.platformLabel(""))
assertEquals("web", table.platformLabel("web"))
assertEquals("KOOK", table.platformLabel("kook"))
}
@Test
fun `platformLabelOrNull separates known from unknown without substituting`() {
assertEquals("网页", catalog.platformLabelOrNull("web"))
assertNull(catalog.platformLabelOrNull("mastodon"))
assertNull(EnumCatalog.Empty.platformLabelOrNull("web"))
}
}
@@ -0,0 +1,91 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import top.yeij.cyrene.data.api.dto.EnumItemDto
import top.yeij.cyrene.data.api.dto.EnumTableDto
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.enums.EnumCatalog
/**
* 来源角标的两种回落(S1.3):
* 平台名查 `/meta/enums` 权威表(查不到原样显示),`deviceId` 的端粒度启发式与平台表无关。
*/
class MessageSourceTest {
private val catalog = EnumCatalog.of(
EnumTableDto(
platforms = listOf(
EnumItemDto("web", "网页"),
EnumItemDto("kook", "KOOK"),
EnumItemDto("telegram", "Telegram"),
),
),
)
private fun message(
role: String = "user",
platform: String? = null,
deviceId: String? = null,
) = HistoryMessageDto(
id = "m1",
sessionId = "web",
role = role,
platform = platform,
deviceId = deviceId,
createdAt = 1_791_088_571_785L,
)
@Test
fun `platform label comes from the authoritative table`() {
// 权威表给 `web` 的标签是「网页」——旧硬编码表写的是 `Web`,此处即那处漂移的回归钉子。
assertEquals("来自 网页", messageSourceLabel(message(platform = "web"), catalog))
assertEquals("来自 KOOK", messageSourceLabel(message(platform = "kook"), catalog))
assertEquals("发送至 Telegram", messageSourceLabel(message(role = "assistant", platform = "telegram"), catalog))
}
@Test
fun `unknown platform is displayed verbatim`() {
assertEquals("来自 mastodon", messageSourceLabel(message(platform = "mastodon"), catalog))
}
@Test
fun `without a table every platform is displayed verbatim rather than dropped`() {
assertEquals("来自 web", messageSourceLabel(message(platform = "web"), EnumCatalog.Empty))
assertEquals("来自 kook", messageSourceLabel(message(platform = "kook"), EnumCatalog.Empty))
}
@Test
fun `device heuristics stay independent of the platform table`() {
assertEquals("来自 安卓", messageSourceLabel(message(deviceId = "voiceassist"), catalog))
assertEquals("来自 Web(chrome)", messageSourceLabel(message(deviceId = "web:chrome:abc"), catalog))
// deviceId 优先于 platform:两者都在时按 device 显示。
assertEquals("来自 安卓", messageSourceLabel(message(platform = "web", deviceId = "voiceassist"), catalog))
}
@Test
fun `known platform prefix of an IM device id resolves to the table label`() {
assertEquals("来自 KOOK", messageSourceLabel(message(deviceId = "kook:bot1:uid9"), catalog))
}
@Test
fun `unknown platform prefix of an IM device id keeps the whole raw device id`() {
assertEquals(
"来自 matrix:acc1:uid9",
messageSourceLabel(message(deviceId = "matrix:acc1:uid9"), catalog),
)
}
@Test
fun `no source at all yields no badge`() {
assertNull(messageSourceLabel(message(), catalog))
assertNull(messageSourceLabel(message(platform = " "), catalog))
assertNull(messageSourceLabel(message(deviceId = ""), catalog))
}
@Test
fun `blank device id falls through to the platform`() {
assertEquals("来自 网页", messageSourceLabel(message(platform = "web", deviceId = " "), catalog))
}
}