From 217cfe3d5fd0cfaaa6f07033ffee10d471dc36f1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 18:06:11 +0800 Subject: [PATCH] =?UTF-8?q?W5:=20=E6=96=B0=E5=BB=BA=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=97=B6=E9=80=89=E8=A7=92=E8=89=B2=EF=BC=88persona=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改了什么: - `POST /api/v1/sessions` 支持可选 `personaId`:null(或全空白)走**不带 body** 的重载 (与选角之前逐字节相同),非空才发 `{"personaId":"…"}`;新增 CreateSessionRequest / CreateSessionResponse.personaId / SessionSummaryDto.personaId。 - 新增**用户侧**角色目录取数:PersonaApi + UserPersonaDto + PersonaRepository(沿用 apiCall/Result)。 刻意不用 admin 的 PersonaSummaryDto/AdminApi:用户侧不下发 admin 专属 thinking,admin 端点还要管理档。 - 抽屉「+」不再直接建会话,而是先开角色弹层(「默认角色」+ 各角色,角色名 + character 简述, 失败/加载中各自有话说);确认后按所选创建。角色表拿不到时降级为「只有默认角色」,不挡建会话。 - 会话行摘要前缀该会话绑定的角色名:能拿到角色表就显示名字,拿不到就原样显示 id,未绑就不显示。 为什么: - 此前 createSession 完全没有 body,新建的会话一律是默认角色,后端 v0.42 起的多角色能力用不上。 - 会话角色**创建后不可变**(`PATCH /sessions/{id}` 一旦收到 personaId 就 400),所以选角这一步 只在建会话时问一次,也必须在建会话时问。 测试 353 → 385(+32),assembleDebug 与 testDebugUnitTest 均通过。 变异验证:把仓库改回「null 也发 body」后,SessionRepositoryCreateTest 有 2 条断言 FAIL。 --- .../top/yeij/cyrene/app/AppContainer.kt | 24 +++ .../top/yeij/cyrene/data/api/PersonaApi.kt | 18 ++ .../top/yeij/cyrene/data/api/SessionsApi.kt | 19 +- .../yeij/cyrene/data/api/dto/PersonaDto.kt | 26 +++ .../yeij/cyrene/data/api/dto/SessionDto.kt | 31 +++- .../data/repository/PersonaRepository.kt | 24 +++ .../data/repository/SessionRepository.kt | 22 ++- .../cyrene/ui/sessions/PersonaSelection.kt | 104 +++++++++++ .../ui/sessions/SessionDrawerContent.kt | 138 ++++++++++++++- .../cyrene/ui/sessions/SessionsViewModel.kt | 47 ++++- .../data/api/dto/PersonaDtoDecodingTest.kt | 98 +++++++++++ .../data/api/dto/SessionDtoRealPayloadTest.kt | 68 ++++++++ .../repository/SessionRepositoryCreateTest.kt | 139 +++++++++++++++ .../ui/sessions/PersonaSelectionTest.kt | 162 ++++++++++++++++++ .../cyrene/ui/sessions/SessionItemTest.kt | 8 + 15 files changed, 917 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/PersonaApi.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/dto/PersonaDto.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/repository/PersonaRepository.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/sessions/PersonaSelection.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/api/dto/PersonaDtoDecodingTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/repository/SessionRepositoryCreateTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/sessions/PersonaSelectionTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index 5b861f6..1a9ef68 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -10,12 +10,14 @@ import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.ChatApi import top.yeij.cyrene.data.api.MetaApi +import top.yeij.cyrene.data.api.PersonaApi import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.UploadApi import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.ChatRepository import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.MetaRepository +import top.yeij.cyrene.data.repository.PersonaRepository import top.yeij.cyrene.data.repository.SessionRepository import top.yeij.cyrene.data.repository.UploadRepository import top.yeij.cyrene.data.ws.ChatSocket @@ -147,6 +149,28 @@ class AppContainer(context: Context) { val metaRepository: MetaRepository get() = MetaRepository(metaApi) + private var cachedPersonaApi: PersonaApi? = null + private var cachedPersonaBaseUrl: String? = null + + /** 用户侧角色目录(`GET /api/v1/personas`);与 [metaApi] 同源(同一 baseUrl、同一 token/401 处理)。 */ + val personaApi: PersonaApi + get() { + val baseUrl = authManager.currentBaseUrl + val cached = cachedPersonaApi + if (cached != null && cachedPersonaBaseUrl == baseUrl) return cached + val created = ApiClientFactory.create( + baseUrl = baseUrl, + service = PersonaApi::class.java, + tokenProvider = authManager::currentToken, + unauthorizedPolicy = authManager.unauthorizedPolicy, + ) + cachedPersonaBaseUrl = baseUrl + cachedPersonaApi = created + return created + } + + val personaRepository: PersonaRepository get() = PersonaRepository(personaApi) + fun createChatSocket(): ChatSocket = ChatSocket( baseUrlProvider = { authManager.currentBaseUrl }, tokenProvider = authManager::currentToken, diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/PersonaApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/PersonaApi.kt new file mode 100644 index 0000000..54fd1fb --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/PersonaApi.kt @@ -0,0 +1,18 @@ +package top.yeij.cyrene.data.api + +import top.yeij.cyrene.data.api.dto.UserPersonaDto +import retrofit2.Response +import retrofit2.http.GET + +/** + * **用户侧**角色目录(api-client §1 行 9)。任意已认证角色可读,无 token 才 `401`。 + * + * 与 [AdminApi.personas] 分开是有意的:admin 端点每项多一个 `thinking`,而且整条路径要求 + * `viewer` 以上的管理档——拿它当用户侧接口用,普通用户身上会拿到 `403`。 + */ +interface PersonaApi { + + /** 角色列表;服务端顺序即权威顺序,客户端不重排。 */ + @GET("api/v1/personas") + suspend fun personas(): Response>> +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt index 75cb2d9..e68617a 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt @@ -1,5 +1,6 @@ package top.yeij.cyrene.data.api +import top.yeij.cyrene.data.api.dto.CreateSessionRequest import top.yeij.cyrene.data.api.dto.CreateSessionResponse import top.yeij.cyrene.data.api.dto.DeleteSessionResponse import top.yeij.cyrene.data.api.dto.HistoryMessageDto @@ -26,10 +27,26 @@ interface SessionsApi { @GET("api/v1/sessions") suspend fun listSessions(): Response>> - /** 无 body;服务端返回 `201 {data:{sessionId}}`。 */ + /** + * 新建次级会话,**完全不带 body**(= 不绑角色,跟随全局默认角色)。 + * + * 这个无参重载是给「不选角色」这条路留的:`personaId` 为 null 时走它,线上请求与本端支持 + * 选角之前**逐字节相同**——不会因为多了一个可选字段而改变任何既有行为。 + * 服务端返回 `201 {data:{sessionId}}`。 + */ @POST("api/v1/sessions") suspend fun createSession(): Response> + /** + * 新建会话并**在创建时绑定角色**(v0.42)。 + * + * `personaId` 为**未知角色** → `400 bad_request`;非空时响应会回显它。 + * 会话角色**只在创建时写入、之后不可变**:`PATCH /sessions/{id}` 收到 `personaId` 一律 `400`, + * 要换角色只能新开会话(见 [UpdateSessionRequest])。 + */ + @POST("api/v1/sessions") + suspend fun createSession(@Body body: CreateSessionRequest): Response> + /** 主会话 `web` 删除会返回 `403 forbidden`。 */ @DELETE("api/v1/sessions/{id}") suspend fun deleteSession(@Path("id") sessionId: String): Response> diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/PersonaDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/PersonaDto.kt new file mode 100644 index 0000000..1c977e0 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/PersonaDto.kt @@ -0,0 +1,26 @@ +package top.yeij.cyrene.data.api.dto + +import kotlinx.serialization.Serializable + +/** + * `GET /api/v1/personas`(**用户侧**角色目录,api-client §1 行 9)的一项。 + * + * 与 admin 侧的 [PersonaSummaryDto] 同源同形,但**用户侧不下发 admin 专属的 `thinking`** + * ——这正是不能拿 admin 端点当用户侧端点用的原因之一(用户侧不暴露模型调用旋钮)。 + * + * [mode] / [mood] 都是**开放集合**:未知取值原样保留(不丢弃、不报错、不占位符),渲染时才映射。 + * `mood` 为空串是「该角色暂无被跟踪的情绪」,不是异常,也不伪造默认值。 + * [chatBackground] 与 admin 侧共用 [ChatBackgroundDto](同一个后端结构)。 + */ +@Serializable +data class UserPersonaDto( + val id: String, + val name: String = "", + val character: String = "", + val isDefault: Boolean = false, + val mode: String = "", + val memoryNamespace: String = "", + val avatar: String? = null, + val chatBackground: ChatBackgroundDto? = null, + val mood: String? = null, +) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt index 9e6341b..4ce75bd 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt @@ -31,6 +31,13 @@ data class SessionSummaryDto( * 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。 */ val unreadCount: Int = 0, + /** + * 该会话绑定的角色 id(v0.42,可选);**空/缺省 = 继承全局默认角色**。 + * + * 只读字段:会话角色**只在创建时**写入,之后不可变(`PATCH` 一旦收到 `personaId` 一律 400), + * 所以它不可能被本端改。默认值是为了旧快照仍能解码(同 [unreadCount] 的理由)。 + */ + val personaId: String? = null, ) { val isMain: Boolean get() = type == TYPE_MAIN || id == MAIN_SESSION_ID @@ -42,10 +49,32 @@ data class SessionSummaryDto( } } -/** `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:"}}`。 */ +/** + * `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:"}}`。 + * + * [personaId] 只在**创建时显式绑了角色**才回显(v0.42);省略时响应形状与 v0.41 完全一致, + * 所以它是可空字段,不能拿它的有无去判断别的事。 + */ @Serializable data class CreateSessionResponse( val sessionId: String, + val personaId: String? = null, +) + +/** + * `POST /api/v1/sessions` 的请求体(v0.42:创建时绑定角色)。 + * + * 服务端把「省略 `personaId`」与「`personaId` 为空」当成同一件事:**不绑** + * (`persona_id=''`,继承全局默认角色)。`personaId` 为**未知角色**时回 `400 bad_request`。 + * + * 生产路径上 null 不会走到这里——仓库对 null 走的是**完全不带 body** 的重载 + * (与本端支持选角之前的线上请求逐字节相同)。这里保留可空字段,是为了让 + * 「null 序列化后整个键缺席」这件事有一处**可被测试钉住**的表达 + * (生产 `Json` 的 `explicitNulls = false`)。 + */ +@Serializable +data class CreateSessionRequest( + val personaId: String? = null, ) /** diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/PersonaRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/PersonaRepository.kt new file mode 100644 index 0000000..aaec251 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/PersonaRepository.kt @@ -0,0 +1,24 @@ +package top.yeij.cyrene.data.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import top.yeij.cyrene.data.api.PersonaApi +import top.yeij.cyrene.data.api.apiCall +import top.yeij.cyrene.data.api.dto.UserPersonaDto + +/** + * 用户侧角色目录取数层(api-client §1 行 9),供「新建会话时选角色」用。 + * + * 仓库不持有持久化状态:调用方失败时保留上一次拿到的列表——角色目录拿不到只会让弹层少几行, + * 不该连带挡掉「用默认角色新建会话」这条降级路径。 + */ +class PersonaRepository( + private val api: PersonaApi, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + + suspend fun personas(): Result> = withContext(dispatcher) { + apiCall { api.personas() } + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt index 50fa180..4f00c15 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt @@ -2,6 +2,7 @@ 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.CreateSessionRequest import top.yeij.cyrene.data.api.dto.ReadSessionRequest import top.yeij.cyrene.data.api.dto.ReadSessionResponse import top.yeij.cyrene.data.api.dto.SessionSummaryDto @@ -30,9 +31,24 @@ class SessionRepository( apiCall { api.listSessions() } } - /** `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:`)。 */ - suspend fun createSession(): Result = withContext(dispatcher) { - apiCall { api.createSession() }.map { it.sessionId } + /** + * `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:`)。 + * + * @param personaId null(或全空白)= **不绑角色**:走**不带 body** 的请求,线上与本端支持选角 + * 之前逐字节相同。空白按 null 处理是有依据的——契约里「省略 `personaId`」与「`personaId` + * 为空」是同一件事(不绑,继承全局默认角色),没必要把 `{"personaId":""}` 发上去。 + * 非空 = 创建时绑定该角色,未知角色服务端回 `400 bad_request`。 + * + * 角色**只在创建时**能定:`PATCH /sessions/{id}` 一旦收到 `personaId` 就回 `400`,要换角色只能新开。 + */ + suspend fun createSession(personaId: String? = null): Result = withContext(dispatcher) { + val persona = personaId?.takeIf { it.isNotBlank() } + val created = if (persona == null) { + apiCall { api.createSession() } + } else { + apiCall { api.createSession(CreateSessionRequest(personaId = persona)) } + } + created.map { it.sessionId } } /** diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/PersonaSelection.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/PersonaSelection.kt new file mode 100644 index 0000000..e5f4b1b --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/PersonaSelection.kt @@ -0,0 +1,104 @@ +package top.yeij.cyrene.ui.sessions + +import top.yeij.cyrene.data.api.dto.UserPersonaDto + +/** + * 「新建会话时选角色」的纯逻辑:弹层选项的构造、会话行上的角色名回填。 + * 抽在这里是为了能直接单测——不依赖 Compose,也不依赖服务端。 + */ + +/** + * 弹层里的一项。[personaId] 为 null 就是**「默认角色」**:不绑 personaId,跟随全局默认角色。 + * + * [title] 一定非空(服务端 `name` 为空白时回落到 `id`);[description] 为 null 表示 + * **没有简述**,此时只显示名字,不编占位符。 + */ +data class PersonaChoice( + val personaId: String?, + val title: String, + val description: String?, +) + +/** 「默认角色」那一行的标题([PersonaChoice.personaId] 为 null 的那一项恒是它)。 */ +internal const val DEFAULT_PERSONA_TITLE = "默认角色" + +/** 「默认角色」的简述:只写契约里写明的事。 */ +private const val DEFAULT_PERSONA_DESCRIPTION = "不绑定角色,跟随全局默认" + +/** + * 构造弹层选项:**「默认角色」恒在第一位**,其余按服务端顺序原样排——服务端是排序的权威, + * 客户端不重排、不去重、不丢弃不认识的取值。 + * + * 角色名空白时回落到 `id`(与管理页同一口径:宁可显示一个 id,也不显示空行); + * `character` 空白 = 没有简述,只显示名字。 + */ +internal fun personaChoices(personas: List): List = buildList { + add( + PersonaChoice( + personaId = null, + title = DEFAULT_PERSONA_TITLE, + description = defaultRoleDescription(personas), + ), + ) + personas.forEach { persona -> + add( + PersonaChoice( + personaId = persona.id, + title = persona.name.ifBlank { persona.id }, + description = persona.character.takeIf { it.isNotBlank() }, + ), + ) + } +} + +/** + * 「默认角色」那一行的简述:能看出全局默认是哪个角色时**点名**它,免得弹层里两行都叫「默认」 + * 分不清——服务端 `isDefault` 标记的那一位就是 `personaId` 省略时的回落目标。 + * + * 角色表里没有任何默认标记(或它没有名字也没有 id)时不点名:**不猜**。 + */ +private fun defaultRoleDescription(personas: List): String { + val fallback = personas.firstOrNull { it.isDefault }?.let { it.name.ifBlank { it.id } } + return if (fallback.isNullOrBlank()) { + DEFAULT_PERSONA_DESCRIPTION + } else { + "$DEFAULT_PERSONA_DESCRIPTION(当前「$fallback」)" + } +} + +/** + * 会话行上的角色名:**只有真的绑了角色(`personaId` 非空白)才显示**。 + * + * - 绑了 + 角色表里有 → 角色名; + * - 绑了 + 角色表里没有(角色表没拉回来 / 角色被改名或删掉)→ **原样显示那个 id**, + * 不猜、不硬编码任何映射表; + * - `personaId` 空/缺省(继承全局默认角色)→ null(不显示):那是绝大多数会话的常态, + * 每行都挂一个「默认」只是噪声。 + */ +internal fun personaLabelFor(personaId: String?, personas: List): String? { + val id = personaId?.takeIf { it.isNotBlank() } ?: return null + return personas.firstOrNull { it.personaId == id }?.title ?: id +} + +/** 会话行的摘要行文本:`[角色名 ·] 最后一条消息预览`;两者都没有 → null(该行不画摘要)。 */ +internal fun sessionSummaryText(personaLabel: String?, preview: String?): String? = + listOfNotNull(personaLabel, preview) + .filter { it.isNotBlank() } + .takeIf { it.isNotEmpty() } + ?.joinToString(" · ") + +/** + * 角色目录的拉取结果 → 新状态(纯函数)。 + * + * **只有成功才动列表**:失败保留上一次拿到的列表(一次网络抖动不该把好数据擦掉), + * 也不把 [SessionsUiState.personasLoaded] 打回 false——拿到过就是拿到过,那时列表里还是有内容的。 + * 失败只把原因写进 [SessionsUiState.personaError]:弹层里显示它,用默认角色建会话这条路不受影响。 + */ +internal fun SessionsUiState.withPersonaResult( + loaded: List? = null, + failure: String? = null, +): SessionsUiState = if (loaded != null) { + copy(personas = personaChoices(loaded), personasLoaded = true, personaError = null) +} else { + copy(personaError = failure) +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt index 5802664..c20d5e2 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt @@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -48,12 +51,20 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme * * 行内交互:单击切换会话、**长按重命名**、行尾置顶/取消置顶;删除只对次级会话开放。 * 标题是服务端的自动标题(首条用户消息生成),没有就落到兜底标题。 + * + * ## 新建会话要先选角色 + * 「+」不再直接建会话,而是先开 [NewSessionDialog]。理由是角色**只在创建时**能定、之后不可变 + * (`PATCH /sessions/{id}` 收到 `personaId` 一律 400),选错了只能删掉重开——这一步值得停下来问一次。 + * + * @param onCreate 参数是这次要绑定的角色 id:**null = 「默认角色」**(不绑,跟随全局默认角色)。 + * 类型必须是 `(String?) -> Unit`:调用方传的是 `SessionsViewModel::createSession` 这个方法引用, + * 两边签名逐字对应。 */ @Composable fun ColumnScope.SessionDrawerContent( state: SessionsUiState, onSelect: (String) -> Unit, - onCreate: () -> Unit, + onCreate: (String?) -> Unit, onDelete: (SessionItem) -> Unit, onTogglePin: (SessionItem) -> Unit, onRename: (String, String) -> Unit, @@ -63,6 +74,7 @@ fun ColumnScope.SessionDrawerContent( ) { var pendingDelete by remember { mutableStateOf(null) } var pendingRename by remember { mutableStateOf(null) } + var pendingNewSession by remember { mutableStateOf(false) } Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) { Text("Cyrene", style = MiuixTheme.textStyles.title2) @@ -80,7 +92,7 @@ fun ColumnScope.SessionDrawerContent( verticalAlignment = Alignment.CenterVertically, ) { Text("会话", style = MiuixTheme.textStyles.subtitle, modifier = Modifier.weight(1f)) - IconButton(onClick = onCreate) { + IconButton(onClick = { pendingNewSession = true }) { Icon(imageVector = MiuixIcons.Add, contentDescription = "新建会话") } } @@ -92,6 +104,9 @@ fun ColumnScope.SessionDrawerContent( items(items = state.sessions, key = { it.id }) { session -> SessionRow( session = session, + // 角色名在这里回填而不是在 VM 里拼进 SessionItem:这里能拿到角色表(也就能表达 + // 「角色表还没回来」的降级),而且 [personaLabelFor] 是一条可单测的纯函数。 + personaLabel = personaLabelFor(session.personaId, state.personas), active = session.id == state.activeSessionId, onClick = { onSelect(session.id) }, onTogglePin = { onTogglePin(session) }, @@ -157,8 +172,118 @@ fun ColumnScope.SessionDrawerContent( onDismiss = { pendingRename = null }, ) } + + if (pendingNewSession) { + NewSessionDialog( + personas = state.personas, + personasLoaded = state.personasLoaded, + personaError = state.personaError, + onConfirm = { personaId -> + onCreate(personaId) + pendingNewSession = false + }, + onDismiss = { pendingNewSession = false }, + ) + } } +/** + * 「新建会话」的角色弹层。 + * + * 用 Compose 的 `Dialog` + Miuix 内容,与同文件的重命名 / 删除确认**同一套做法**:抽屉只有 300dp 宽, + * 角色列表需要一块能盖住全屏的空间,也没必要去猜 Miuix 的弹层组件 API。选项行仍是 + * `PreferenceCard { BasicComponent(...) }`(Miuix 列表项的底座,自身没有背景与圆角)。 + * + * 选中项是一个 `String?`,与 [PersonaChoice.personaId] 同一口径(null = 「默认角色」); + * **每次打开都从「默认角色」开始**——新建会话是一次新决定,不沿用上一次的选择。 + * + * 角色目录没拉回来时,这里只剩「默认角色」:**正在拉**与**服务端真的没有角色**要分开说 + * ([personasLoaded]),失败则把原因也摆出来。降级目标是「照样能用默认角色建会话」, + * 而不是把新建会话整个挡掉。 + */ +@Composable +private fun NewSessionDialog( + personas: List, + personasLoaded: Boolean, + personaError: String?, + onConfirm: (String?) -> Unit, + onDismiss: () -> Unit, +) { + var selected by remember { mutableStateOf(null) } + + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp)) + .padding(20.dp), + ) { + Text("新建会话", style = MiuixTheme.textStyles.title3) + Spacer(Modifier.height(4.dp)) + Text( + text = "选择这个会话的角色。角色创建后不可更改,要换角色请新开会话。", + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + Spacer(Modifier.height(12.dp)) + + Column( + modifier = Modifier + .heightIn(max = PERSONA_LIST_MAX_HEIGHT) + .verticalScroll(rememberScrollState()), + ) { + personas.forEach { choice -> + PreferenceCard { + BasicComponent( + title = choice.title, + summary = choice.description, + onClick = { selected = choice.personaId }, + endActions = { + if (selected == choice.personaId) { + Text( + text = "✓", + style = MiuixTheme.textStyles.main, + color = MiuixTheme.colorScheme.primary, + ) + } + }, + ) + } + Spacer(Modifier.height(8.dp)) + } + } + + if (!personasLoaded && personaError == null) { + // 「还没拉到」不能长得像「服务端没有角色」——那会让人以为角色功能没了。 + Text( + text = "角色列表加载中…", + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + Spacer(Modifier.height(8.dp)) + } + + if (personaError != null) { + Text( + text = "角色列表加载失败:$personaError", + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton("取消", onClick = onDismiss) + Spacer(Modifier.width(8.dp)) + TextButton("创建", onClick = { onConfirm(selected) }) + } + } + } +} + +/** 弹层里角色列表的最大高度:超出就滚动,别把「创建」按钮挤出窄屏的可见区。 */ +private val PERSONA_LIST_MAX_HEIGHT = 320.dp + /** * 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按, * `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable, @@ -176,10 +301,14 @@ fun ColumnScope.SessionDrawerContent( * `body2.fontSize` + `summaryColor()`),**唯一的差异是收紧了行数**: * 标题与摘要都 `maxLines = 1` + 省略号——这正是「可读性要强」要的效果:长标题不再在约 116dp 的 * 列里折成两三行把卡片撑高,而是稳定地占一行、尾部省略,列表因此等高、可扫读。 + * + * @param personaLabel 该会话绑定的**角色名**([personaLabelFor] 的回填;拿不到就不显示)。 + * 它挂在摘要行前面——「这个会话是谁在答话」比最后一条消息更该先被看到。 */ @Composable private fun SessionRow( session: SessionItem, + personaLabel: String?, active: Boolean, onClick: () -> Unit, onTogglePin: () -> Unit, @@ -189,6 +318,7 @@ private fun SessionRow( val titleColor = BasicComponentDefaults.titleColor().color val summaryColor = BasicComponentDefaults.summaryColor().color val badge = unreadBadgeText(session.unreadCount) + val summary = sessionSummaryText(personaLabel, session.lastMessage) PreferenceCard { BasicComponent( @@ -254,9 +384,9 @@ private fun SessionRow( } } } - session.lastMessage?.let { preview -> + summary?.let { line -> Text( - text = preview, + text = line, fontSize = MiuixTheme.textStyles.body2.fontSize, color = summaryColor, maxLines = 1, diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt index e129692..b54ff1c 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt @@ -29,6 +29,11 @@ data class SessionItem( val lastMessageAt: Long? = null, /** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */ val unreadCount: Int = 0, + /** + * 该会话绑定的角色 id(服务端 `personaId`,v0.42);null = 未绑(继承全局默认角色)。 + * 只用来在列表行上回填角色名,见 [personaLabelFor]。 + */ + val personaId: String? = null, ) data class SessionsUiState( @@ -39,6 +44,17 @@ data class SessionsUiState( val fromCache: Boolean = false, val syncedAt: Long = 0L, val error: String? = null, + /** + * 角色目录(`GET /personas`),渲染「新建会话」的角色弹层用;第一项恒是「默认角色」。 + * + * 拿不到时它就是空的——弹层里只剩「默认角色」,新建会话照样能走完。这是**有意的降级**: + * 角色目录拿不到不该挡掉建会话。 + */ + val personas: List = emptyList(), + /** 角色目录**已经成功拉回过一次**。用来把「还没拉到」与「服务端就是没有角色」分开。 */ + val personasLoaded: Boolean = false, + /** 角色目录拉取失败的原因。与会话列表的 [error] 分开:它只影响弹层里那几行,不动列表状态。 */ + val personaError: String? = null, ) /** @@ -134,6 +150,24 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { super.onCleared() } + /** + * 拉角色目录(用户侧 `GET /personas`),供「新建会话」的角色弹层用。 + * + * **跟着 [refresh] 走**:初始化 / 新建 / 删除 / 抽屉打开,与会话列表同一节奏——角色也可能在 + * 管理侧被改名或增删,而那些改动**没有任何 WS 帧**会推过来,只能靠这里对齐。 + * + * 失败**只记 [SessionsUiState.personaError]**,并**保留上一次拿到的列表**(一次网络抖动不该把 + * 好数据擦掉);不碰会话列表的错误态——角色目录拿不到只影响「能不能选角色」, + * 用默认角色新建会话这条路照样通。 + */ + private fun loadPersonas() { + viewModelScope.launch { + container.personaRepository.personas() + .onSuccess { list -> _state.update { it.withPersonaResult(loaded = list) } } + .onFailure { e -> _state.update { it.withPersonaResult(failure = e.userMessage()) } } + } + } + /** * 拉取服务端列表。 * @@ -141,6 +175,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { * 只是去对齐服务端状态的场景(抽屉打开、改名/置顶之后),避免闪一下横幅。 */ fun refresh(silent: Boolean = false) { + loadPersonas() viewModelScope.launch { if (!silent) _state.update { it.copy(loading = true) } container.sessionRepository.listSessions() @@ -195,9 +230,15 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { pushReadCursor(sessionId) } - fun createSession() { + /** + * 新建会话;[personaId] 为 null 表示**「默认角色」**(不绑 personaId,跟随全局默认角色)。 + * + * 角色**只在创建时**能定:服务端 `PATCH /sessions/{id}` 一旦收到 `personaId` 就回 `400`, + * 所以本端没有「会话内换角色」这个动作,选角只发生在这里。 + */ + fun createSession(personaId: String? = null) { viewModelScope.launch { - container.sessionRepository.createSession() + container.sessionRepository.createSession(personaId) .onSuccess { sessionId -> refresh() select(sessionId) @@ -340,6 +381,8 @@ internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem( lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() }, lastMessageAt = lastMessage?.createdAt?.takeIf { it > 0 }, unreadCount = unreadCount, + // 空串与缺省同义(契约:空/缺省 = 继承全局默认角色),统一收成 null——UI 上只有「绑没绑」两种。 + personaId = personaId?.takeIf { it.isNotBlank() }, ) /** diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/PersonaDtoDecodingTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/PersonaDtoDecodingTest.kt new file mode 100644 index 0000000..13d4446 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/PersonaDtoDecodingTest.kt @@ -0,0 +1,98 @@ +package top.yeij.cyrene.data.api.dto + +import kotlinx.serialization.builtins.ListSerializer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.data.api.ApiClientFactory +import top.yeij.cyrene.data.api.ApiEnvelope + +/** + * **用户侧**角色目录(`GET /api/v1/personas`,api-client §1 行 9)的解码测试。 + * + * 用户侧与 admin 侧同形,但**不下发 admin 专属的 `thinking`**——用的是独立的 [UserPersonaDto], + * 这张测试同时钉住「多出 admin 专属键也不许崩」。手写载荷覆盖线上暂未出现、但契约允许的边界 + * (未知字段、整块缺字段、未知枚举取值)。 + */ +class PersonaDtoDecodingTest { + + private val json = ApiClientFactory.json + + private val personaList = ListSerializer(UserPersonaDto.serializer()) + + private fun decode(body: String): List = + json.decodeFromString(ApiEnvelope.serializer(personaList), body).data!! + + @Test + fun `a full user side row decodes field by field`() { + val personas = decode( + """ + {"data":[{"id":"xilian","name":"昔涟","character":"memokeeper","isDefault":true, + "mode":"chat","memoryNamespace":"xilian","avatar":"/a.png", + "chatBackground":{"landscape":"/l.png","portrait":"/p.png"},"mood":"calm"}]} + """.trimIndent(), + ) + + val persona = personas.single() + + assertEquals("xilian", persona.id) + assertEquals("昔涟", persona.name) + assertEquals("memokeeper", persona.character) + assertTrue(persona.isDefault) + assertEquals("chat", persona.mode) + assertEquals("xilian", persona.memoryNamespace) + assertEquals("/a.png", persona.avatar) + assertEquals("/l.png", persona.chatBackground?.landscape) + assertEquals("/p.png", persona.chatBackground?.portrait) + assertEquals("calm", persona.mood) + } + + @Test + fun `a minimal row decodes with documented defaults instead of failing`() { + val persona = decode("""{"data":[{"id":"xilian"}]}""").single() + + assertEquals("xilian", persona.id) + assertEquals("", persona.name) + assertEquals("", persona.character) + assertFalse(persona.isDefault) + assertEquals("", persona.mode) + assertEquals("", persona.memoryNamespace) + assertNull(persona.avatar) + assertNull(persona.chatBackground) + assertNull(persona.mood) + } + + @Test + fun `a blank mood means no tracked mood and is not faked`() { + // `""` = 该角色暂无被跟踪的情绪(契约明说),不是「后端没报告」,也不许伪造默认值。 + assertEquals("", decode("""{"data":[{"id":"xilian","mood":""}]}""").single().mood) + } + + @Test + fun `unknown mode and mood are kept raw instead of being dropped`() { + val persona = decode("""{"data":[{"id":"xilian","mode":"telepathy","mood":"euphoric"}]}""").single() + + assertEquals("telepathy", persona.mode) + assertEquals("euphoric", persona.mood) + } + + @Test + fun `unknown keys are ignored and never break the decode`() { + // 同一个后端结构还会长:admin 专属的 thinking、以及将来任何新键,用户侧遇到都不能炸。 + val persona = decode( + """ + {"data":[{"id":"xilian","name":"昔涟","thinking":{"budgetTokens":1,"effort":"low"}, + "nickname":"小昔","unknownList":[1,2]}]} + """.trimIndent(), + ).single() + + assertEquals("昔涟", persona.name) + } + + @Test + fun `an empty persona list decodes to an empty list`() { + assertTrue(decode("""{"data":[]}""").isEmpty()) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/SessionDtoRealPayloadTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/SessionDtoRealPayloadTest.kt index a9e5c8f..bd73ff2 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/SessionDtoRealPayloadTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/SessionDtoRealPayloadTest.kt @@ -229,4 +229,72 @@ class SessionDtoRealPayloadTest { assertEquals("web", data.sessionId) assertEquals(1_753_000_000_000L, data.readAt) } + + // —— 新建会话时选角色(契约 v0.42) —— + + @Test + fun `a create request without a persona omits the key entirely`() { + val body = json.encodeToString(CreateSessionRequest.serializer(), CreateSessionRequest()) + + // 服务端把「省略 personaId」与「personaId 为空」当同一件事(不绑,继承全局默认角色)。 + // 生产路径对 null 走的是**完全不带 body** 的重载(见 SessionRepositoryCreateTest); + // 这里钉的是:真要序列化它,出来的也必须是 `{}`,绝不能是 `{"personaId":null}`。 + assertEquals("{}", body) + assertFalse(body, body.contains("personaId")) + } + + @Test + fun `a create request with a persona sends exactly one key`() { + val body = json.encodeToString( + CreateSessionRequest.serializer(), + CreateSessionRequest(personaId = "xilian"), + ) + + assertEquals("""{"personaId":"xilian"}""", body) + } + + @Test + fun `a create response echoes the persona only when the session got bound`() { + val bound = json.decodeFromString( + ApiEnvelope.serializer(CreateSessionResponse.serializer()), + """{"data":{"sessionId":"web:abc","personaId":"xilian"}}""", + ).data!! + + assertEquals("web:abc", bound.sessionId) + assertEquals("xilian", bound.personaId) + + // 不绑时响应形状与 v0.41 完全一致:`personaId` 整个键缺席。 + val unbound = json.decodeFromString( + ApiEnvelope.serializer(CreateSessionResponse.serializer()), + """{"data":{"sessionId":"web:abc"}}""", + ).data!! + + assertEquals("web:abc", unbound.sessionId) + assertNull(unbound.personaId) + } + + @Test + fun `a session row carries the optional persona id`() { + val bound = json.decodeFromString( + SessionSummaryDto.serializer(), + """{"id":"web:abc","type":"sub","createdAt":1,"pinned":false,"unreadCount":0,"personaId":"xilian"}""", + ) + assertEquals("xilian", bound.personaId) + + // 未绑的会话:键整个缺席(= 继承全局默认角色)。这是绝大多数行的形状。 + val unbound = json.decodeFromString( + SessionSummaryDto.serializer(), + """{"id":"web","type":"main","createdAt":1}""", + ) + assertNull(unbound.personaId) + } + + @Test + fun `a legacy snapshot without the persona key still decodes`() { + // 本版之前写下的那份快照里没有 personaId:升级后整份快照必须照样解得开, + // 否则第一次启动会先闪一下空列表(同 unreadCount 的那条理由)。 + val legacy = """{"id":"web","type":"main","createdAt":1788724031196,"pinned":false,"unreadCount":0}""" + + assertNull(json.decodeFromString(SessionSummaryDto.serializer(), legacy).personaId) + } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/SessionRepositoryCreateTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/SessionRepositoryCreateTest.kt new file mode 100644 index 0000000..d0fa8c3 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/SessionRepositoryCreateTest.kt @@ -0,0 +1,139 @@ +package top.yeij.cyrene.data.repository + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.ApiClientFactory +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.data.api.SessionsApi +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors + +/** + * `POST /sessions` 的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带 HTTP 服务端 + * (不引新依赖,与 [UploadRepositoryTest] / [ChatRepositoryTest] 同一手法)。 + * + * 这里要钉住的是一件很容易改坏的事:**不选角色时发出去的请求必须与引入选角之前逐字节相同**。 + * 服务端 body 完全可选,「多一个 `personaId` 键」不是无害的——那会变成一次真的绑定尝试 + * (未知角色直接 `400`,而 `""` 还有它自己的语义)。所以 null 走的是**不带 body** 的重载, + * 而不是 `{}`。 + */ +class SessionRepositoryCreateTest { + + private val backend = FakeSessionsBackend() + + @After + fun tearDown() { + backend.close() + } + + private fun repository() = SessionRepository( + ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java), + ) + + @Test + fun `creating without a persona sends a body of zero bytes`() = runBlocking { + backend.reply(201, """{"data":{"sessionId":"web:abc"}}""") + + val created = repository().createSession().getOrThrow() + + assertEquals("web:abc", created) + val request = backend.requests.single() + assertEquals("POST", request.method) + assertEquals("/api/v1/sessions", request.path) + // **一个字节都没有**:连 `{}` 都不是——契约说 body「可完全省略」,那就省略。 + assertEquals("", request.body) + } + + @Test + fun `a blank persona id is treated as no persona at all`() = runBlocking { + backend.reply(201, """{"data":{"sessionId":"web:abc"}}""") + + repository().createSession(" ").getOrThrow() + + // 「省略」与「空」在服务端是同一件事(不绑),所以没必要把 `{"personaId":""}` 发上去。 + assertEquals("", backend.requests.single().body) + } + + @Test + fun `creating with a persona sends exactly that one key`() = runBlocking { + backend.reply(201, """{"data":{"sessionId":"web:abc","personaId":"xilian"}}""") + + repository().createSession("xilian").getOrThrow() + + assertEquals("""{"personaId":"xilian"}""", backend.requests.single().body) + } + + @Test + fun `an unknown persona stays a server failure instead of silently falling back`() = runBlocking { + backend.reply(400, """{"error":{"code":"bad_request","message":"unknown persona"}}""") + + val error = repository().createSession("ghost").exceptionOrNull() as CyreneException + + // 绝不「失败就改用默认角色重试」:那会建出一个用户没要的会话。 + assertEquals(CyreneError.Server("bad_request", "unknown persona", 400), error.error) + } + + @Test + fun `a 201 without data is a parse failure`() = runBlocking { + backend.reply(201, "{}") + + val error = repository().createSession().exceptionOrNull() as CyreneException + + assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error) + } + + /** 记录每次请求的原始字节,按 `reply(...)` 回话的假后端。 */ + private inner class FakeSessionsBackend : AutoCloseable { + + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + executor = Executors.newFixedThreadPool(2) + createContext("/api/v1/sessions", ::handle) + start() + } + + val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/" + + val requests = CopyOnWriteArrayList() + + @Volatile + private var status: Int = 201 + + @Volatile + private var body: String = "" + + fun reply(status: Int, body: String) { + this.status = status + this.body = body + } + + private fun handle(exchange: HttpExchange) { + // ISO-8859-1:字节 ↔ 字符一一对应,断言里比对的就是线上真实字节。 + val raw = exchange.requestBody.readBytes() + requests += RecordedRequest( + method = exchange.requestMethod, + path = exchange.requestURI.path, + body = raw.toString(Charsets.ISO_8859_1), + ) + val bytes = body.toByteArray() + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + override fun close() { + runCatching { server.stop(0) } + } + } + + private data class RecordedRequest( + val method: String, + val path: String, + val body: String, + ) +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/PersonaSelectionTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/PersonaSelectionTest.kt new file mode 100644 index 0000000..5b25595 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/PersonaSelectionTest.kt @@ -0,0 +1,162 @@ +package top.yeij.cyrene.ui.sessions + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.data.api.dto.UserPersonaDto + +/** + * 「新建会话时选角色」的三条纯逻辑:弹层选项构造、会话行上的角色名回填、摘要行拼接。 + * 都不依赖 Compose,也不依赖服务端。 + */ +class PersonaSelectionTest { + + private fun persona( + id: String, + name: String = "", + character: String = "", + isDefault: Boolean = false, + ) = UserPersonaDto( + id = id, + name = name, + character = character, + isDefault = isDefault, + ) + + // —— 弹层选项 —— + + @Test + fun `the default role is always the first choice and binds nothing`() { + val choices = personaChoices(listOf(persona("xilian", name = "昔涟", character = "memokeeper"))) + + assertEquals(2, choices.size) + assertNull(choices[0].personaId) + assertEquals(DEFAULT_PERSONA_TITLE, choices[0].title) + assertEquals("xilian", choices[1].personaId) + assertEquals("昔涟", choices[1].title) + } + + @Test + fun `an empty persona list still offers the default role`() { + // 角色目录没拉回来(或真的没有角色)时弹层不能是空的——那样连用默认角色建会话都做不到。 + val choices = personaChoices(emptyList()) + + assertEquals(1, choices.size) + assertNull(choices.single().personaId) + assertEquals(DEFAULT_PERSONA_TITLE, choices.single().title) + } + + @Test + fun `the server order is kept verbatim and no role is dropped`() { + val choices = personaChoices(listOf(persona("b", name = "第二个"), persona("a"), persona("z"))) + + assertEquals(listOf("b", "a", "z"), choices.drop(1).map { it.personaId }) + } + + @Test + fun `a blank name falls back to the id and a blank character means no summary`() { + val choices = personaChoices(listOf(persona("xilian", name = " ", character = " "))) + + assertEquals("xilian", choices[1].title) + assertNull(choices[1].description) + } + + @Test + fun `the character field becomes the summary verbatim`() { + // character 是开放集合:未知取值原样显示,客户端不做映射、也不丢。 + val choices = personaChoices(listOf(persona("xilian", name = "昔涟", character = "memokeeper"))) + + assertEquals("memokeeper", choices[1].description) + } + + @Test + fun `the default role names the server side default when there is one`() { + // 弹层里会出现两行「默认」:点名当前全局默认角色才不会让人选错。 + val choices = personaChoices(listOf(persona("xilian", name = "昔涟", isDefault = true))) + + assertEquals("不绑定角色,跟随全局默认(当前「昔涟」)", choices[0].description) + } + + @Test + fun `the default role does not guess when nothing is marked default`() { + val choices = personaChoices(listOf(persona("xilian", name = "昔涟"))) + + assertEquals("不绑定角色,跟随全局默认", choices[0].description) + } + + // —— 会话行上的角色名 —— + + @Test + fun `a bound and known persona shows its name`() { + val choices = personaChoices(listOf(persona("xilian", name = "昔涟"))) + + assertEquals("昔涟", personaLabelFor("xilian", choices)) + } + + @Test + fun `a bound but unknown persona shows the raw id`() { + // 角色表没拉回来、或那个角色已经被删掉:原样显示 id,不猜、也不硬编码映射表。 + assertEquals("ghost", personaLabelFor("ghost", personaChoices(emptyList()))) + } + + @Test + fun `an unbound session shows nothing at all`() { + val choices = personaChoices(listOf(persona("xilian", name = "昔涟"))) + + assertNull(personaLabelFor(null, choices)) + assertNull(personaLabelFor("", choices)) + assertNull(personaLabelFor(" ", choices)) + } + + // —— 摘要行 —— + + @Test + fun `the persona label is prefixed to the preview`() { + assertEquals("昔涟 · 她说她一直在", sessionSummaryText("昔涟", "她说她一直在")) + } + + @Test + fun `either half alone is enough for a summary line`() { + assertEquals("昔涟", sessionSummaryText("昔涟", null)) + assertEquals("她说她一直在", sessionSummaryText(null, "她说她一直在")) + assertNull(sessionSummaryText(null, null)) + assertNull(sessionSummaryText(" ", " ")) + } + + // —— 拉取结果 → 状态(只有成功才动列表) —— + + @Test + fun `a successful load replaces the list and marks it loaded`() { + val state = SessionsUiState(personaError = "上一次失败").withPersonaResult( + loaded = listOf(persona("xilian", name = "昔涟")), + ) + + assertEquals(listOf(null, "xilian"), state.personas.map { it.personaId }) + assertTrue(state.personasLoaded) + assertNull(state.personaError) + } + + @Test + fun `a failed load keeps the previous list instead of wiping it`() { + val previous = SessionsUiState().withPersonaResult(loaded = listOf(persona("xilian", name = "昔涟"))) + + val failed = previous.withPersonaResult(failure = "连接失败") + + assertEquals(previous.personas, failed.personas) + // 拿到过就是拿到过:一次刷新失败不该让弹层退回「加载中…」。 + assertTrue(failed.personasLoaded) + assertEquals("连接失败", failed.personaError) + } + + @Test + fun `a failure before the first success is an error, not a pretend empty list`() { + val failed = SessionsUiState().withPersonaResult(failure = "连接失败") + + assertTrue(failed.personas.isEmpty()) + // 这一条决定弹层显示「加载中…」还是失败原因——两者绝不能混在一起。 + assertFalse(failed.personasLoaded) + assertEquals("连接失败", failed.personaError) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionItemTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionItemTest.kt index 9eb8820..65e16e6 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionItemTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionItemTest.kt @@ -124,4 +124,12 @@ class SessionItemTest { // 空列表是异常响应,不该拿它去改用户的选择。 assertEquals("web:alive", resolveActiveSession("web:alive", emptyList())) } + + @Test + fun `toItem keeps a bound persona and folds a blank one to null`() { + assertEquals("xilian", dto().copy(personaId = "xilian").toItem().personaId) + // 契约:空串与缺省同义(都是继承全局默认角色)——UI 上只该有「绑没绑」两种,空串不能漏到行上。 + assertNull(dto().copy(personaId = "").toItem().personaId) + assertNull(dto().toItem().personaId) + } }