W5: 新建会话时选角色(persona)

改了什么:
- `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。
This commit is contained in:
AskaEth committed 2026-10-04 18:06:11 +08:00
1 parent 664fd8dcef
commit 217cfe3d5f
15 files changed
+917 -11

No files matched your search

@@ -10,12 +10,14 @@ 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.ChatApi import top.yeij.cyrene.data.api.ChatApi
import top.yeij.cyrene.data.api.MetaApi 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.SessionsApi
import top.yeij.cyrene.data.api.UploadApi import top.yeij.cyrene.data.api.UploadApi
import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.AdminRepository
import top.yeij.cyrene.data.repository.ChatRepository import top.yeij.cyrene.data.repository.ChatRepository
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.MetaRepository
import top.yeij.cyrene.data.repository.PersonaRepository
import top.yeij.cyrene.data.repository.SessionRepository import top.yeij.cyrene.data.repository.SessionRepository
import top.yeij.cyrene.data.repository.UploadRepository import top.yeij.cyrene.data.repository.UploadRepository
import top.yeij.cyrene.data.ws.ChatSocket import top.yeij.cyrene.data.ws.ChatSocket
@@ -147,6 +149,28 @@ class AppContainer(context: Context) {
val metaRepository: MetaRepository get() = MetaRepository(metaApi) 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( fun createChatSocket(): ChatSocket = ChatSocket(
baseUrlProvider = { authManager.currentBaseUrl }, baseUrlProvider = { authManager.currentBaseUrl },
tokenProvider = authManager::currentToken, tokenProvider = authManager::currentToken,
@@ -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<ApiEnvelope<List<UserPersonaDto>>>
}
@@ -1,5 +1,6 @@
package top.yeij.cyrene.data.api 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.CreateSessionResponse
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.data.api.dto.HistoryMessageDto
@@ -26,10 +27,26 @@ interface SessionsApi {
@GET("api/v1/sessions") @GET("api/v1/sessions")
suspend fun listSessions(): Response<ApiEnvelope<List<SessionSummaryDto>>> suspend fun listSessions(): Response<ApiEnvelope<List<SessionSummaryDto>>>
/** 无 body;服务端返回 `201 {data:{sessionId}}`。 */ /**
* 新建次级会话,**完全不带 body**(= 不绑角色,跟随全局默认角色)。
*
* 这个无参重载是给「不选角色」这条路留的:`personaId` 为 null 时走它,线上请求与本端支持
* 选角之前**逐字节相同**——不会因为多了一个可选字段而改变任何既有行为。
* 服务端返回 `201 {data:{sessionId}}`。
*/
@POST("api/v1/sessions") @POST("api/v1/sessions")
suspend fun createSession(): Response<ApiEnvelope<CreateSessionResponse>> suspend fun createSession(): Response<ApiEnvelope<CreateSessionResponse>>
/**
* 新建会话并**在创建时绑定角色**(v0.42)。
*
* `personaId` 为**未知角色** → `400 bad_request`;非空时响应会回显它。
* 会话角色**只在创建时写入、之后不可变**:`PATCH /sessions/{id}` 收到 `personaId` 一律 `400`,
* 要换角色只能新开会话(见 [UpdateSessionRequest])。
*/
@POST("api/v1/sessions")
suspend fun createSession(@Body body: CreateSessionRequest): Response<ApiEnvelope<CreateSessionResponse>>
/** 主会话 `web` 删除会返回 `403 forbidden`。 */ /** 主会话 `web` 删除会返回 `403 forbidden`。 */
@DELETE("api/v1/sessions/{id}") @DELETE("api/v1/sessions/{id}")
suspend fun deleteSession(@Path("id") sessionId: String): Response<ApiEnvelope<DeleteSessionResponse>> suspend fun deleteSession(@Path("id") sessionId: String): Response<ApiEnvelope<DeleteSessionResponse>>
@@ -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,
)
@@ -31,6 +31,13 @@ data class SessionSummaryDto(
* 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。 * 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。
*/ */
val unreadCount: Int = 0, val unreadCount: Int = 0,
/**
* 该会话绑定的角色 id(v0.42,可选);**空/缺省 = 继承全局默认角色**。
*
* 只读字段:会话角色**只在创建时**写入,之后不可变(`PATCH` 一旦收到 `personaId` 一律 400),
* 所以它不可能被本端改。默认值是为了旧快照仍能解码(同 [unreadCount] 的理由)。
*/
val personaId: String? = null,
) { ) {
val isMain: Boolean val isMain: Boolean
get() = type == TYPE_MAIN || id == MAIN_SESSION_ID get() = type == TYPE_MAIN || id == MAIN_SESSION_ID
@@ -42,10 +49,32 @@ data class SessionSummaryDto(
} }
} }
/** `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:<uuid>"}}`。 */ /**
* `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:<uuid>"}}`。
*
* [personaId] 只在**创建时显式绑了角色**才回显(v0.42);省略时响应形状与 v0.41 完全一致,
* 所以它是可空字段,不能拿它的有无去判断别的事。
*/
@Serializable @Serializable
data class CreateSessionResponse( data class CreateSessionResponse(
val sessionId: String, 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,
) )
/** /**
@@ -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<List<UserPersonaDto>> = withContext(dispatcher) {
apiCall { api.personas() }
}
}
@@ -2,6 +2,7 @@ package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.apiCall 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.ReadSessionRequest
import top.yeij.cyrene.data.api.dto.ReadSessionResponse import top.yeij.cyrene.data.api.dto.ReadSessionResponse
import top.yeij.cyrene.data.api.dto.SessionSummaryDto import top.yeij.cyrene.data.api.dto.SessionSummaryDto
@@ -30,9 +31,24 @@ class SessionRepository(
apiCall { api.listSessions() } apiCall { api.listSessions() }
} }
/** `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:<uuid>`)。 */ /**
suspend fun createSession(): Result<String> = withContext(dispatcher) { * `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:<uuid>`)。
apiCall { api.createSession() }.map { it.sessionId } *
* @param personaId null(或全空白)= **不绑角色**:走**不带 body** 的请求,线上与本端支持选角
* 之前逐字节相同。空白按 null 处理是有依据的——契约里「省略 `personaId`」与「`personaId`
* 为空」是同一件事(不绑,继承全局默认角色),没必要把 `{"personaId":""}` 发上去。
* 非空 = 创建时绑定该角色,未知角色服务端回 `400 bad_request`。
*
* 角色**只在创建时**能定:`PATCH /sessions/{id}` 一旦收到 `personaId` 就回 `400`,要换角色只能新开。
*/
suspend fun createSession(personaId: String? = null): Result<String> = 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 }
} }
/** /**
@@ -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<UserPersonaDto>): List<PersonaChoice> = 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<UserPersonaDto>): 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<PersonaChoice>): 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<UserPersonaDto>? = null,
failure: String? = null,
): SessionsUiState = if (loaded != null) {
copy(personas = personaChoices(loaded), personasLoaded = true, personaError = null)
} else {
copy(personaError = failure)
}
@@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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 @Composable
fun ColumnScope.SessionDrawerContent( fun ColumnScope.SessionDrawerContent(
state: SessionsUiState, state: SessionsUiState,
onSelect: (String) -> Unit, onSelect: (String) -> Unit,
onCreate: () -> Unit, onCreate: (String?) -> Unit,
onDelete: (SessionItem) -> Unit, onDelete: (SessionItem) -> Unit,
onTogglePin: (SessionItem) -> Unit, onTogglePin: (SessionItem) -> Unit,
onRename: (String, String) -> Unit, onRename: (String, String) -> Unit,
@@ -63,6 +74,7 @@ fun ColumnScope.SessionDrawerContent(
) { ) {
var pendingDelete by remember { mutableStateOf<SessionItem?>(null) } var pendingDelete by remember { mutableStateOf<SessionItem?>(null) }
var pendingRename by remember { mutableStateOf<SessionItem?>(null) } var pendingRename by remember { mutableStateOf<SessionItem?>(null) }
var pendingNewSession by remember { mutableStateOf(false) }
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) { Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) {
Text("Cyrene", style = MiuixTheme.textStyles.title2) Text("Cyrene", style = MiuixTheme.textStyles.title2)
@@ -80,7 +92,7 @@ fun ColumnScope.SessionDrawerContent(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("会话", style = MiuixTheme.textStyles.subtitle, modifier = Modifier.weight(1f)) Text("会话", style = MiuixTheme.textStyles.subtitle, modifier = Modifier.weight(1f))
IconButton(onClick = onCreate) { IconButton(onClick = { pendingNewSession = true }) {
Icon(imageVector = MiuixIcons.Add, contentDescription = "新建会话") Icon(imageVector = MiuixIcons.Add, contentDescription = "新建会话")
} }
} }
@@ -92,6 +104,9 @@ fun ColumnScope.SessionDrawerContent(
items(items = state.sessions, key = { it.id }) { session -> items(items = state.sessions, key = { it.id }) { session ->
SessionRow( SessionRow(
session = session, session = session,
// 角色名在这里回填而不是在 VM 里拼进 SessionItem:这里能拿到角色表(也就能表达
// 「角色表还没回来」的降级),而且 [personaLabelFor] 是一条可单测的纯函数。
personaLabel = personaLabelFor(session.personaId, state.personas),
active = session.id == state.activeSessionId, active = session.id == state.activeSessionId,
onClick = { onSelect(session.id) }, onClick = { onSelect(session.id) },
onTogglePin = { onTogglePin(session) }, onTogglePin = { onTogglePin(session) },
@@ -157,8 +172,118 @@ fun ColumnScope.SessionDrawerContent(
onDismiss = { pendingRename = null }, 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<PersonaChoice>,
personasLoaded: Boolean,
personaError: String?,
onConfirm: (String?) -> Unit,
onDismiss: () -> Unit,
) {
var selected by remember { mutableStateOf<String?>(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` 负责单击/长按, * 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按,
* `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable, * `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable,
@@ -176,10 +301,14 @@ fun ColumnScope.SessionDrawerContent(
* `body2.fontSize` + `summaryColor()`),**唯一的差异是收紧了行数**: * `body2.fontSize` + `summaryColor()`),**唯一的差异是收紧了行数**:
* 标题与摘要都 `maxLines = 1` + 省略号——这正是「可读性要强」要的效果:长标题不再在约 116dp 的 * 标题与摘要都 `maxLines = 1` + 省略号——这正是「可读性要强」要的效果:长标题不再在约 116dp 的
* 列里折成两三行把卡片撑高,而是稳定地占一行、尾部省略,列表因此等高、可扫读。 * 列里折成两三行把卡片撑高,而是稳定地占一行、尾部省略,列表因此等高、可扫读。
*
* @param personaLabel 该会话绑定的**角色名**([personaLabelFor] 的回填;拿不到就不显示)。
* 它挂在摘要行前面——「这个会话是谁在答话」比最后一条消息更该先被看到。
*/ */
@Composable @Composable
private fun SessionRow( private fun SessionRow(
session: SessionItem, session: SessionItem,
personaLabel: String?,
active: Boolean, active: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onTogglePin: () -> Unit, onTogglePin: () -> Unit,
@@ -189,6 +318,7 @@ private fun SessionRow(
val titleColor = BasicComponentDefaults.titleColor().color val titleColor = BasicComponentDefaults.titleColor().color
val summaryColor = BasicComponentDefaults.summaryColor().color val summaryColor = BasicComponentDefaults.summaryColor().color
val badge = unreadBadgeText(session.unreadCount) val badge = unreadBadgeText(session.unreadCount)
val summary = sessionSummaryText(personaLabel, session.lastMessage)
PreferenceCard { PreferenceCard {
BasicComponent( BasicComponent(
@@ -254,9 +384,9 @@ private fun SessionRow(
} }
} }
} }
session.lastMessage?.let { preview -> summary?.let { line ->
Text( Text(
text = preview, text = line,
fontSize = MiuixTheme.textStyles.body2.fontSize, fontSize = MiuixTheme.textStyles.body2.fontSize,
color = summaryColor, color = summaryColor,
maxLines = 1, maxLines = 1,
@@ -29,6 +29,11 @@ data class SessionItem(
val lastMessageAt: Long? = null, val lastMessageAt: Long? = null,
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */ /** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
val unreadCount: Int = 0, val unreadCount: Int = 0,
/**
* 该会话绑定的角色 id(服务端 `personaId`,v0.42);null = 未绑(继承全局默认角色)。
* 只用来在列表行上回填角色名,见 [personaLabelFor]。
*/
val personaId: String? = null,
) )
data class SessionsUiState( data class SessionsUiState(
@@ -39,6 +44,17 @@ data class SessionsUiState(
val fromCache: Boolean = false, val fromCache: Boolean = false,
val syncedAt: Long = 0L, val syncedAt: Long = 0L,
val error: String? = null, val error: String? = null,
/**
* 角色目录(`GET /personas`),渲染「新建会话」的角色弹层用;第一项恒是「默认角色」。
*
* 拿不到时它就是空的——弹层里只剩「默认角色」,新建会话照样能走完。这是**有意的降级**:
* 角色目录拿不到不该挡掉建会话。
*/
val personas: List<PersonaChoice> = emptyList(),
/** 角色目录**已经成功拉回过一次**。用来把「还没拉到」与「服务端就是没有角色」分开。 */
val personasLoaded: Boolean = false,
/** 角色目录拉取失败的原因。与会话列表的 [error] 分开:它只影响弹层里那几行,不动列表状态。 */
val personaError: String? = null,
) )
/** /**
@@ -134,6 +150,24 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
super.onCleared() 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) { fun refresh(silent: Boolean = false) {
loadPersonas()
viewModelScope.launch { viewModelScope.launch {
if (!silent) _state.update { it.copy(loading = true) } if (!silent) _state.update { it.copy(loading = true) }
container.sessionRepository.listSessions() container.sessionRepository.listSessions()
@@ -195,9 +230,15 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
pushReadCursor(sessionId) pushReadCursor(sessionId)
} }
fun createSession() { /**
* 新建会话;[personaId] 为 null 表示**「默认角色」**(不绑 personaId,跟随全局默认角色)。
*
* 角色**只在创建时**能定:服务端 `PATCH /sessions/{id}` 一旦收到 `personaId` 就回 `400`,
* 所以本端没有「会话内换角色」这个动作,选角只发生在这里。
*/
fun createSession(personaId: String? = null) {
viewModelScope.launch { viewModelScope.launch {
container.sessionRepository.createSession() container.sessionRepository.createSession(personaId)
.onSuccess { sessionId -> .onSuccess { sessionId ->
refresh() refresh()
select(sessionId) select(sessionId)
@@ -340,6 +381,8 @@ internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() }, lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() },
lastMessageAt = lastMessage?.createdAt?.takeIf { it > 0 }, lastMessageAt = lastMessage?.createdAt?.takeIf { it > 0 },
unreadCount = unreadCount, unreadCount = unreadCount,
// 空串与缺省同义(契约:空/缺省 = 继承全局默认角色),统一收成 null——UI 上只有「绑没绑」两种。
personaId = personaId?.takeIf { it.isNotBlank() },
) )
/** /**
@@ -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<UserPersonaDto> =
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())
}
}
@@ -229,4 +229,72 @@ class SessionDtoRealPayloadTest {
assertEquals("web", data.sessionId) assertEquals("web", data.sessionId)
assertEquals(1_753_000_000_000L, data.readAt) 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)
}
} }
@@ -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<RecordedRequest>()
@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,
)
}
@@ -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)
}
}
@@ -124,4 +124,12 @@ class SessionItemTest {
// 空列表是异常响应,不该拿它去改用户的选择。 // 空列表是异常响应,不该拿它去改用户的选择。
assertEquals("web:alive", resolveActiveSession("web:alive", emptyList())) 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)
}
} }