feat(sessions): 会话列表用服务端标题 + 置顶 + 最后消息预览(A4/D5/B3)

列表此前显示的是本地硬生成的「会话 N」,因为后端 title 恒空。后端后来做了
「首条用户消息自动命名」+ PATCH 改名/置顶 + lastMessage 预览,本轮接入,
并把本地那套序号机器整块删掉。

- data:SessionSummaryDto 补 pinned / lastMessage(均带默认值,老快照仍能解);
  新增 UpdateSessionRequest / Response 与 @PATCH api/v1/sessions/{id}
- core/prefs:**整块退役**本地序号机器(order / KEY_ORDER / OrderSerializer /
  decodeOrder / ensureOrder / 「会话 ?」)——有了服务端标题,本地序号永远不会一致,
  且 id 没有序号时会渲染出字面的「会话 ?」
- 标题规则:服务端标题非空白即原样显示,否则兜底(主会话「与昔涟」/ 次级「新会话」)。
  主会话刻意保留「与昔涟」而不采用服务端自己的「主会话」
- 顺序**完全信任服务端**(pinned DESC, main DESC, created_at ASC),客户端不重排;
  因此置顶的次级会排在主会话之前——顺带修正 SessionDto 里「主会话恒首位」的过时注释
- 置顶:主会话与次级都可置顶(契约允许);先就地翻转拿即时反馈 → PATCH →
  成功后静默重拉(服务端才是顺序权威,快照顺带修正)→ 失败回滚并报错
- 改名:长按行打开对话框(预填当前标题 + n/80 计数),不做乐观更新(服务端会 trim 后
  回写,等静默重拉取权威值);**不提供「恢复自动命名」**——服务端无法表达(空白即 400)
- **刷新触发(前置条件,不是锦上添花)**:列表此前只在 VM 初始化 / 新建 / 删除时拉取,
  所以自动标题与预览要重启 App 才可见。现改为**打开抽屉时静默刷新**,
  且不闪「同步中…」横幅
- 顺带修同文件里两个既有缺陷:① **快照的指纹闸门其实没实现**(fingerprint 写了却从不
  比较、clear() 是死代码)→ 现按 baseUrl+username 指纹过滤,并把 clear() 接到登出
  (否则换账号后第一帧会闪上个账号的会话列表);已实测确认指纹与写入时逐字节一致,
  不会误丢老快照。② 预览剥标签时旁白与正文会粘连(看截图发现的排版瑕疵)→
  标签改替成空格再折叠空白

测试:175 全绿(153 → +22)。含真实抓取响应体的 DTO 契约测试、标题兜底与预览规则的
纯函数测试、指纹闸门测试。
This commit is contained in:
AskaEth committed 2026-10-04 13:55:52 +08:00
1 parent f5576540b4
commit c09a21136c
10 files changed
+654 -71

No files matched your search

@@ -10,9 +10,7 @@ import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer import top.yeij.cyrene.data.api.ApiClientFactory
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import top.yeij.cyrene.data.api.dto.SessionSummaryDto import top.yeij.cyrene.data.api.dto.SessionSummaryDto
private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_sessions") private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_sessions")
@@ -23,23 +21,23 @@ private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataS
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染 * @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」) * @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
* @param fingerprint `baseUrl + username`;换服务器/账号后快照作废 * @param fingerprint `baseUrl + username`;换服务器/账号后快照作废
* @param order `sessionId -> 显示序号`(后端 title 恒空,序号本地生成)
* @param activeSessionId 上次停留的会话 * @param activeSessionId 上次停留的会话
*/ */
data class SessionPrefs( data class SessionPrefs(
val snapshot: List<SessionSummaryDto> = emptyList(), val snapshot: List<SessionSummaryDto> = emptyList(),
val snapshotAt: Long = 0L, val snapshotAt: Long = 0L,
val fingerprint: String = "", val fingerprint: String = "",
val order: Map<String, Int> = emptyMap(),
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID, val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
) ) {
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的会话列表拿来用。 */
fun snapshotFor(fingerprint: String): List<SessionSummaryDto> =
snapshot.takeIf { this.fingerprint == fingerprint }.orEmpty()
}
class SessionPreferences(context: Context) { class SessionPreferences(context: Context) {
private val json = Json { private val json = ApiClientFactory.json
ignoreUnknownKeys = true
explicitNulls = false
}
private val store: DataStore<Preferences> = context.applicationContext.sessionDataStore private val store: DataStore<Preferences> = context.applicationContext.sessionDataStore
@@ -48,29 +46,16 @@ class SessionPreferences(context: Context) {
snapshot = prefs[KEY_SNAPSHOT].decodeSessions(), snapshot = prefs[KEY_SNAPSHOT].decodeSessions(),
snapshotAt = prefs[KEY_SNAPSHOT_AT] ?: 0L, snapshotAt = prefs[KEY_SNAPSHOT_AT] ?: 0L,
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(), fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
order = prefs[KEY_ORDER].decodeOrder(),
activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID, activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID,
) )
} }
/** /** 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵)。 */
* 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵),
* 同时为新出现的次级会话分配显示序号、清理已消失会话的序号。
*/
suspend fun saveSnapshot(sessions: List<SessionSummaryDto>, fingerprint: String) { suspend fun saveSnapshot(sessions: List<SessionSummaryDto>, fingerprint: String) {
store.edit { prefs -> store.edit { prefs ->
val sameAccount = prefs[KEY_FINGERPRINT].orEmpty() == fingerprint
val order = if (sameAccount) prefs[KEY_ORDER].decodeOrder().toMutableMap() else mutableMapOf()
var next = order.values.maxOrNull() ?: 0
sessions.filterNot { it.isMain }.forEach { dto ->
if (order[dto.id] == null) order[dto.id] = ++next
}
order.keys.retainAll(sessions.map { it.id }.toSet())
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions) prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions)
prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis() prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis()
prefs[KEY_FINGERPRINT] = fingerprint prefs[KEY_FINGERPRINT] = fingerprint
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
} }
} }
@@ -78,30 +63,9 @@ class SessionPreferences(context: Context) {
store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId } store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId }
} }
/** 分配显示序号(当前最大值 + 1);已分配过则原样返回。 */ /** 删除会话:必要时把 active 退回主会话,并同步从快照里剔除。 */
suspend fun ensureOrder(sessionId: String): Int {
var assigned = 0
store.edit { prefs ->
val order = prefs[KEY_ORDER].decodeOrder().toMutableMap()
val existing = order[sessionId]
if (existing != null) {
assigned = existing
} else {
assigned = (order.values.maxOrNull() ?: 0) + 1
order[sessionId] = assigned
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
}
}
return assigned
}
/** 删除会话:清序号、必要时把 active 退回主会话、同步从快照里剔除。 */
suspend fun removeSession(sessionId: String) { suspend fun removeSession(sessionId: String) {
store.edit { prefs -> store.edit { prefs ->
val order = prefs[KEY_ORDER].decodeOrder().toMutableMap()
order.remove(sessionId)
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
if (prefs[KEY_ACTIVE] == sessionId) { if (prefs[KEY_ACTIVE] == sessionId) {
prefs[KEY_ACTIVE] = SessionSummaryDto.MAIN_SESSION_ID prefs[KEY_ACTIVE] = SessionSummaryDto.MAIN_SESSION_ID
} }
@@ -111,7 +75,7 @@ class SessionPreferences(context: Context) {
} }
} }
/** 登出 / 切换服务器时清空。 */ /** 登出时清空:快照、指纹、active 一起走,下次登录不会先闪一份旧列表。 */
suspend fun clear() { suspend fun clear() {
store.edit { it.clear() } store.edit { it.clear() }
} }
@@ -120,18 +84,12 @@ class SessionPreferences(context: Context) {
?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() } ?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() }
.orEmpty() .orEmpty()
private fun String?.decodeOrder(): Map<String, Int> = this
?.let { runCatching { json.decodeFromString(OrderSerializer, it) }.getOrNull() }
.orEmpty()
private companion object { private companion object {
val KEY_SNAPSHOT = stringPreferencesKey("sessions_snapshot") val KEY_SNAPSHOT = stringPreferencesKey("sessions_snapshot")
val KEY_SNAPSHOT_AT = longPreferencesKey("sessions_snapshot_at") val KEY_SNAPSHOT_AT = longPreferencesKey("sessions_snapshot_at")
val KEY_FINGERPRINT = stringPreferencesKey("sessions_fingerprint") val KEY_FINGERPRINT = stringPreferencesKey("sessions_fingerprint")
val KEY_ORDER = stringPreferencesKey("sessions_order")
val KEY_ACTIVE = stringPreferencesKey("active_session_id") val KEY_ACTIVE = stringPreferencesKey("active_session_id")
val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer()) val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer())
val OrderSerializer = MapSerializer(String.serializer(), Int.serializer())
} }
} }
@@ -4,9 +4,13 @@ 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
import top.yeij.cyrene.data.api.dto.SessionSummaryDto import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
@@ -28,6 +32,21 @@ interface SessionsApi {
@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>>
/**
* 重命名 / 置顶(`writeAuth`:owner 或 admin)。主会话与次级会话都能改。
*
* - 请求体至少要带一个字段;`{}` → 400。字段缺席即保留原值。
* - `title` 会被 trim;空/全空白 → 400 `title is required`(措辞误导,实际含义是
* **无法把标题清回自动命名**);超过 80 个字符 → 400。
* - 两个字段同时给出时**先改名再置顶**:置顶失败时改名已经落库,不能当成「什么都没发生」。
* - 响应是局部形状,见 [UpdateSessionResponse]。
*/
@PATCH("api/v1/sessions/{id}")
suspend fun updateSession(
@Path("id") sessionId: String,
@Body body: UpdateSessionRequest,
): Response<ApiEnvelope<UpdateSessionResponse>>
/** /**
* 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。 * 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。
* @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制) * @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制)
@@ -4,7 +4,13 @@ import kotlinx.serialization.Serializable
/** /**
* `GET /api/v1/sessions` 持久化会话摘要(api-reference §3.2 A)。 * `GET /api/v1/sessions` 持久化会话摘要(api-reference §3.2 A)。
* 主会话 `id` 恒为 `"web"`、`type = "main"`,且由服务端排在首位。 *
* 主会话 `id` 恒为 `"web"`、`type = "main"`,但**不恒在首位**:
* 服务端排序是 `pinned DESC, type = 'main' DESC, created_at ASC`,
* 被置顶的次级会话合法地排在主会话前面。
*
* [title] 在会话还没有任何用户消息时**整个键缺席**(自动命名只在那时写入)——
* 这是正常状态,不是异常;渲染时用客户端兜底标题。
*/ */
@Serializable @Serializable
data class SessionSummaryDto( data class SessionSummaryDto(
@@ -12,6 +18,9 @@ data class SessionSummaryDto(
val type: String, val type: String,
val title: String? = null, val title: String? = null,
val createdAt: Long, val createdAt: Long,
val pinned: Boolean = false,
/** 会话还没有消息时**整个键缺席**。 */
val lastMessage: SessionLastMessageDto? = null,
) { ) {
val isMain: Boolean val isMain: Boolean
get() = type == TYPE_MAIN || id == MAIN_SESSION_ID get() = type == TYPE_MAIN || id == MAIN_SESSION_ID
@@ -29,6 +38,45 @@ data class CreateSessionResponse(
val sessionId: String, val sessionId: String,
) )
/**
* 会话最后一条消息的摘要(服务端 `historyLastMessage`)。
*
* [content] 已被服务端折叠空白、截断到 60 个字符 + `…`,但**保留原始的 `<p>` / `<action>` 标记**:
* 直接拿来显示会露出标签,渲染前必须剥掉。
*/
@Serializable
data class SessionLastMessageDto(
val role: String,
val content: String = "",
val createdAt: Long,
)
/**
* `PATCH /api/v1/sessions/{id}` 的请求体。
*
* 服务端逐字段用指针判断:**字段缺席即保留原值**,没有「省略即清空」的陷阱;
* 因此这里也必须可空,并且不能被序列化成显式 `null`——
* 生产 `Json` 的 `explicitNulls = false` 正是保证这一点的地方。
*
* **绝不能加 `personaId`**:服务端对任何值的 `personaId` 都先判 400。
*/
@Serializable
data class UpdateSessionRequest(
val title: String? = null,
val pinned: Boolean? = null,
)
/**
* `PATCH /api/v1/sessions/{id}` 的响应是**局部**形状(`{id, title?}` 和/或 `{id, pinned?}`),
* 不是完整的会话行——绝不能拿它当列表行用。
*/
@Serializable
data class UpdateSessionResponse(
val id: String = "",
val title: String? = null,
val pinned: Boolean? = null,
)
/** `DELETE /api/v1/sessions/{id}` → `200 {"data":{"deleted":"<id>"}}`。 */ /** `DELETE /api/v1/sessions/{id}` → `200 {"data":{"deleted":"<id>"}}`。 */
@Serializable @Serializable
data class DeleteSessionResponse( data class DeleteSessionResponse(
@@ -3,12 +3,14 @@ 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.SessionSummaryDto import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* 会话列表 / 新建 / 删除的数据获取层。 * 会话列表 / 新建 / 重命名置顶 / 删除的数据获取层。
* *
* 列表策略(§3.1):调用方拿到结果后**整体替换**本地快照,不做 merge; * 列表策略(§3.1):调用方拿到结果后**整体替换**本地快照,不做 merge;
* 失败时调用方保留旧快照。仓库本身不持有任何持久化状态。 * 失败时调用方保留旧快照。仓库本身不持有任何持久化状态。
@@ -18,7 +20,10 @@ class SessionRepository(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) { ) {
/** `GET /sessions`,原样返回服务端顺序(主会话恒首位)。 */ /**
* `GET /sessions`,原样返回服务端顺序——**不做客户端排序**:
* `pinned DESC, type='main' DESC, created_at ASC`,置顶的次级会话合法地排在主会话前面。
*/
suspend fun listSessions(): Result<List<SessionSummaryDto>> = withContext(dispatcher) { suspend fun listSessions(): Result<List<SessionSummaryDto>> = withContext(dispatcher) {
apiCall { api.listSessions() } apiCall { api.listSessions() }
} }
@@ -28,6 +33,18 @@ class SessionRepository(
apiCall { api.createSession() }.map { it.sessionId } apiCall { api.createSession() }.map { it.sessionId }
} }
/**
* `PATCH /sessions/{id}`:只发本次要改的字段,缺席的字段服务端保持原值
* (所以 `null` 是「不改」,不是「清空」——服务端也无法把标题清回自动命名)。
*/
suspend fun updateSession(
sessionId: String,
title: String? = null,
pinned: Boolean? = null,
): Result<UpdateSessionResponse> = withContext(dispatcher) {
apiCall { api.updateSession(sessionId, UpdateSessionRequest(title = title, pinned = pinned)) }
}
/** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */ /** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) { suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
apiCall { api.deleteSession(sessionId) }.map { it.deleted } apiCall { api.deleteSession(sessionId) }.map { it.deleted }
@@ -52,12 +52,20 @@ fun ChatHomeScreen(
else -> MiuixDrawer( else -> MiuixDrawer(
open = drawerOpen, open = drawerOpen,
onOpenChange = { drawerOpen = it }, // 抽屉只会被外部入口打开(MiuixDrawer 的 onOpenChange 只回报关闭),
// 所以这里不用管「打开」分支。
onOpenChange = { open -> drawerOpen = open },
content = { content = {
ChatScreen( ChatScreen(
container = container, container = container,
sessionId = state.activeSessionId, sessionId = state.activeSessionId,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = {
drawerOpen = true
// 抽屉打开时静默重拉:列表只在 init / 新建 / 删除时取过,而服务端的
// 自动标题与置顶顺序**没有任何 WS 帧**会推过来,只能靠这里对齐。
// 静默是为了不闪「同步中…」——屏幕上本来就有内容。
sessionsViewModel.refresh(silent = true)
},
onLogout = onLogout, onLogout = onLogout,
) )
}, },
@@ -70,6 +78,8 @@ fun ChatHomeScreen(
}, },
onCreate = sessionsViewModel::createSession, onCreate = sessionsViewModel::createSession,
onDelete = { sessionsViewModel.deleteSession(it.id) }, onDelete = { sessionsViewModel.deleteSession(it.id) },
onTogglePin = { sessionsViewModel.setPinned(it.id, !it.pinned) },
onRename = { sessionId, title -> sessionsViewModel.renameSession(sessionId, title) },
onOpenIot = { /* P2:IoT 页面 */ }, onOpenIot = { /* P2:IoT 页面 */ },
onOpenAdmin = { onOpenAdmin = {
drawerOpen = false drawerOpen = false
@@ -1,7 +1,7 @@
package top.yeij.cyrene.ui.sessions package top.yeij.cyrene.ui.sessions
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
@@ -29,23 +29,34 @@ import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Add import top.yukonga.miuix.kmp.icon.extended.Add
import top.yukonga.miuix.kmp.icon.extended.Delete import top.yukonga.miuix.kmp.icon.extended.Delete
import top.yukonga.miuix.kmp.icon.extended.Pin
import top.yukonga.miuix.kmp.icon.extended.Unpin
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 侧边栏内容:会话列表 + IoT/设置入口。列表项为 MD3 小圆角卡片。 */ /**
* 侧边栏内容:会话列表 + IoT/设置入口。列表项为 MD3 小圆角卡片。
*
* 行内交互:单击切换会话、**长按重命名**、行尾置顶/取消置顶;删除只对次级会话开放。
* 标题是服务端的自动标题(首条用户消息生成),没有就落到兜底标题。
*/
@Composable @Composable
fun ColumnScope.SessionDrawerContent( fun ColumnScope.SessionDrawerContent(
state: SessionsUiState, state: SessionsUiState,
onSelect: (String) -> Unit, onSelect: (String) -> Unit,
onCreate: () -> Unit, onCreate: () -> Unit,
onDelete: (SessionItem) -> Unit, onDelete: (SessionItem) -> Unit,
onTogglePin: (SessionItem) -> Unit,
onRename: (String, String) -> Unit,
onOpenIot: () -> Unit, onOpenIot: () -> Unit,
onOpenAdmin: () -> Unit, onOpenAdmin: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
) { ) {
var pendingDelete by remember { mutableStateOf<SessionItem?>(null) } var pendingDelete by remember { mutableStateOf<SessionItem?>(null) }
var pendingRename by remember { mutableStateOf<SessionItem?>(null) }
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)
@@ -77,11 +88,22 @@ fun ColumnScope.SessionDrawerContent(
session = session, session = session,
active = session.id == state.activeSessionId, active = session.id == state.activeSessionId,
onClick = { onSelect(session.id) }, onClick = { onSelect(session.id) },
onTogglePin = { onTogglePin(session) },
onRename = { pendingRename = session },
onDelete = { pendingDelete = session }, onDelete = { pendingDelete = session },
) )
} }
} }
if (state.error != null) {
Text(
text = state.error.orEmpty(),
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
)
}
if (state.fromCache && state.syncedAt > 0) { if (state.fromCache && state.syncedAt > 0) {
Text( Text(
text = "网络异常,显示 ${relativeTime(state.syncedAt)} 的结果", text = "网络异常,显示 ${relativeTime(state.syncedAt)} 的结果",
@@ -118,19 +140,38 @@ fun ColumnScope.SessionDrawerContent(
onDismiss = { pendingDelete = null }, onDismiss = { pendingDelete = null },
) )
} }
pendingRename?.let { target ->
RenameDialog(
initial = target.title,
onConfirm = { title ->
onRename(target.id, title)
pendingRename = null
},
onDismiss = { pendingRename = null },
)
}
} }
/**
* 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按,
* `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable,
* 子级会先吃掉按下事件,长按就永远不会触发)。
*/
@Composable @Composable
private fun SessionRow( private fun SessionRow(
session: SessionItem, session: SessionItem,
active: Boolean, active: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onTogglePin: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
PreferenceCard { PreferenceCard {
BasicComponent( BasicComponent(
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onRename),
title = session.title, title = session.title,
onClick = onClick, summary = session.lastMessage,
endActions = { endActions = {
if (active) { if (active) {
Text( Text(
@@ -140,6 +181,17 @@ private fun SessionRow(
modifier = Modifier.padding(horizontal = 4.dp), modifier = Modifier.padding(horizontal = 4.dp),
) )
} }
IconButton(onClick = onTogglePin) {
Icon(
imageVector = if (session.pinned) MiuixIcons.Unpin else MiuixIcons.Pin,
contentDescription = if (session.pinned) "取消置顶" else "置顶",
tint = if (session.pinned) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
}
if (!session.isMain) { if (!session.isMain) {
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon( Icon(
@@ -154,6 +206,59 @@ private fun SessionRow(
} }
} }
/** 重命名对话框:服务端不接受空标题(400)也不接受超过 80 个字符,两者都在这里挡住。 */
@Composable
private fun RenameDialog(
initial: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember(initial) { mutableStateOf(initial) }
val trimmed = name.trim()
val used = trimmed.runes()
val tooLong = used > TITLE_MAX_RUNES
val canSave = trimmed.isNotEmpty() && !tooLong
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(12.dp))
TextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
label = "会话名称",
singleLine = true,
)
Spacer(Modifier.height(6.dp))
Text(
text = "$used / $TITLE_MAX_RUNES",
style = MiuixTheme.textStyles.footnote2,
color = if (tooLong) {
MiuixTheme.colorScheme.error
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
Spacer(Modifier.height(20.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton("取消", onClick = onDismiss)
Spacer(Modifier.width(8.dp))
TextButton(
text = "保存",
onClick = { onConfirm(trimmed) },
enabled = canSave,
)
}
}
}
}
@Composable @Composable
private fun ConfirmDialog( private fun ConfirmDialog(
title: String, title: String,
@@ -186,6 +291,12 @@ private fun ConfirmDialog(
} }
} }
/** 服务端 `PATCH /sessions/{id}` 的上限:超过 80 个字符直接 400。 */
private const val TITLE_MAX_RUNES = 80
/** 按 Unicode 码点计数,与服务端 Go 的「rune」口径一致(代理对算一个)。 */
private fun String.runes(): Int = codePointCount(0, length)
private fun relativeTime(timestamp: Long): String { private fun relativeTime(timestamp: Long): String {
val minutes = (System.currentTimeMillis() - timestamp) / 60_000 val minutes = (System.currentTimeMillis() - timestamp) / 60_000
return when { return when {
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.data.api.CyreneException import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.SessionSummaryDto import top.yeij.cyrene.data.api.dto.SessionSummaryDto
@@ -16,6 +17,9 @@ data class SessionItem(
val id: String, val id: String,
val title: String, val title: String,
val isMain: Boolean, val isMain: Boolean,
val pinned: Boolean,
/** 最后一条消息的一行摘要;会话还没有消息时为 null。 */
val lastMessage: String?,
) )
data class SessionsUiState( data class SessionsUiState(
@@ -30,7 +34,9 @@ data class SessionsUiState(
/** /**
* 会话列表(§3.1): * 会话列表(§3.1):
* 启动先用本地快照渲染,再拉服务端;成功则整体替换并回写快照,失败则保留快照并提示。 * 启动先用本地快照渲染(指纹不匹配就不用),再拉服务端;成功则整体替换并回写快照,
* 失败则保留快照并提示。顺序完全信任服务端(`pinned DESC, main DESC, created_at ASC`),
* 客户端不重排。
*/ */
class SessionsViewModel(private val container: AppContainer) : ViewModel() { class SessionsViewModel(private val container: AppContainer) : ViewModel() {
@@ -41,29 +47,51 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
init { init {
viewModelScope.launch { viewModelScope.launch {
val fingerprint = fingerprint()
val cached = prefs.data.first() val cached = prefs.data.first()
val snapshot = cached.snapshotFor(fingerprint)
_state.update { _state.update {
it.copy( it.copy(
sessions = cached.snapshot.map { dto -> dto.toItem(cached.order) }, sessions = snapshot.map { dto -> dto.toItem() },
activeSessionId = cached.activeSessionId, activeSessionId = if (snapshot.isEmpty()) {
fromCache = cached.snapshot.isNotEmpty(), SessionSummaryDto.MAIN_SESSION_ID
syncedAt = cached.snapshotAt, } else {
cached.activeSessionId
},
fromCache = snapshot.isNotEmpty(),
syncedAt = if (snapshot.isEmpty()) 0L else cached.snapshotAt,
) )
} }
refresh() refresh()
} }
// 登出时把快照一起清掉(先例:EnumCatalogViewModel 在登出时清枚举快照)——
// 否则换账号后第一帧会闪一份上个账号的会话列表。
viewModelScope.launch {
container.authManager.state.collect { auth ->
if (auth == SessionState.LoggedOut) {
prefs.clear()
_state.value = SessionsUiState()
}
}
}
} }
fun refresh() { /**
* 拉取服务端列表。
*
* @param silent 静默刷新:**不动**“同步中…”横幅。用于界面上已经有内容、
* 只是去对齐服务端状态的场景(抽屉打开、改名/置顶之后),避免闪一下横幅。
*/
fun refresh(silent: Boolean = false) {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(loading = true) } if (!silent) _state.update { it.copy(loading = true) }
container.sessionRepository.listSessions() container.sessionRepository.listSessions()
.onSuccess { list -> .onSuccess { list ->
prefs.saveSnapshot(list, fingerprint()) prefs.saveSnapshot(list, fingerprint())
val cached = prefs.data.first() val cached = prefs.data.first()
_state.update { _state.update {
it.copy( it.copy(
sessions = list.map { dto -> dto.toItem(cached.order) }, sessions = list.map { dto -> dto.toItem() },
loading = false, loading = false,
fromCache = false, fromCache = false,
syncedAt = cached.snapshotAt, syncedAt = cached.snapshotAt,
@@ -114,17 +142,82 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
} }
} }
/**
* 置顶 / 取消置顶。主会话也能置顶(契约允许)。
*
* 先就地翻转 `pinned` 拿到即时反馈,再发 PATCH;成功后**静默重拉**——
* 服务端才是顺序的权威(置顶会把会话排到最前),快照也顺带被修正。
* 失败则回滚到调用前的列表并报错。
*/
fun setPinned(sessionId: String, pinned: Boolean) {
val before = _state.value.sessions
_state.update { state ->
state.copy(
sessions = state.sessions.map { if (it.id == sessionId) it.copy(pinned = pinned) else it },
)
}
viewModelScope.launch {
container.sessionRepository.updateSession(sessionId, pinned = pinned)
.onSuccess { refresh(silent = true) }
.onFailure { e ->
_state.update { it.copy(sessions = before, error = e.userMessage()) }
}
}
}
/**
* 重命名。不做乐观更新:服务端会 trim 后回写,等静默重拉拿到权威标题。
* 调用方必须保证 [title] 非空白且不超过 80 个字符(服务端会 400)。
*/
fun renameSession(sessionId: String, title: String) {
viewModelScope.launch {
container.sessionRepository.updateSession(sessionId, title = title.trim())
.onSuccess { refresh(silent = true) }
.onFailure { e -> _state.update { it.copy(error = e.userMessage()) } }
}
}
fun dismissError() = _state.update { it.copy(error = null) } fun dismissError() = _state.update { it.copy(error = null) }
private fun fingerprint(): String = private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}" "${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
} }
private fun SessionSummaryDto.toItem(order: Map<String, Int>): SessionItem = SessionItem( internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
id = id, id = id,
title = if (isMain) "与昔涟" else "会话 ${order[id] ?: "?"}", title = displayTitle(),
isMain = isMain, isMain = isMain,
pinned = pinned,
lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() },
) )
/**
* 列表行标题:优先用服务端的自动标题(首条用户消息生成),
* 会话还没有用户消息时 `title` 键整个缺席(或为空白),此时才兜底。
*
* 主会话兜底用「与昔涟」而不是服务端的「主会话」——这是个人应用,标题更贴切。
*/
fun SessionSummaryDto.displayTitle(): String = title?.takeIf { it.isNotBlank() }
?: if (isMain) MAIN_SESSION_TITLE else NEW_SESSION_TITLE
internal const val MAIN_SESSION_TITLE = "与昔涟"
internal const val NEW_SESSION_TITLE = "新会话"
/**
* 预览文本:服务端的 `content` 只折叠空白 + 截断到 60 字,`<p>` / `<action>` / `<narration>`
* 标记**原样保留**(截断还可能把标签截在半路),直接显示会露出标签,所以这里剥掉标签、折成一行。
*
* 注意标签替成**空格**而不是空串:旁白紧贴正文时(`…凑近了一点<action>悄悄戳了戳你</action>今天下午…`),
* 替成空串会把两侧的字粘成一个词。
*/
internal fun String.toPreviewText(): String = replace(PREVIEW_TAG_RE, " ")
.replace(WHITESPACE_RE, " ")
.trim()
/** 只剥本应用认识的三种标记;`\b` 保证 `<pre>` 这类同前缀标签不会被误伤。 */
private val PREVIEW_TAG_RE = Regex("</?\\s*(?:p|action|narration)\\b[^>]*>")
private val WHITESPACE_RE = Regex("\\s+")
private fun Throwable.userMessage(): String = private fun Throwable.userMessage(): String =
(this as? CyreneException)?.error?.userMessage ?: "操作失败,请稍后重试" (this as? CyreneException)?.error?.userMessage ?: "操作失败,请稍后重试"
@@ -0,0 +1,51 @@
package top.yeij.cyrene.core.prefs
import org.junit.Assert.assertEquals
import org.junit.Test
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
/**
* 会话快照的指纹闸门:`baseUrl + username` 变了,上一份快照就作废——
* 绝不把别的服务器/账号的会话列表拿来渲染。
*
* 纯粹的 seam 测试:`SessionPreferences` 本体要 Android + DataStore,这里只钉规则。
*/
class SessionPrefsTest {
private val snapshot = listOf(
SessionSummaryDto(
id = "web",
type = SessionSummaryDto.TYPE_MAIN,
title = null,
createdAt = 1L,
),
)
private val fingerprint = "http://host:10942/|testuser"
@Test
fun `a matching fingerprint yields the snapshot`() {
val prefs = SessionPrefs(snapshot = snapshot, fingerprint = fingerprint)
assertEquals(snapshot, prefs.snapshotFor(fingerprint))
}
@Test
fun `another account or another server invalidates the snapshot`() {
val prefs = SessionPrefs(snapshot = snapshot, fingerprint = fingerprint)
assertEquals(emptyList<SessionSummaryDto>(), prefs.snapshotFor("http://host:10942/|someone-else"))
assertEquals(emptyList<SessionSummaryDto>(), prefs.snapshotFor("http://other-host:10942/|testuser"))
// 老安装里没有指纹(或指纹恰好为空的极端情况):同样不认。
assertEquals(emptyList<SessionSummaryDto>(), prefs.snapshotFor(""))
}
@Test
fun `no snapshot yet stays empty even for the matching fingerprint`() {
assertEquals(
emptyList<SessionSummaryDto>(),
SessionPrefs(fingerprint = fingerprint).snapshotFor(fingerprint),
)
assertEquals(emptyList<SessionSummaryDto>(), SessionPrefs().snapshotFor(""))
}
}
@@ -0,0 +1,167 @@
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
/**
* 会话契约钉死测试:用的是**线上后端实际抓下来的**响应体
* (`http://127.0.0.1:10942`,owner 账号,2026-10-05),逐字节照抄(含 `\u003c` 形式的 HTML 转义)。
*
* 解码器用 [ApiClientFactory.json] —— 与生产同一份 `Json` 配置
* (`ignoreUnknownKeys` / `explicitNulls=false` / `coerceInputValues`),
* 所以这里过不了的形状,线上也一定过不了。
*
* 覆盖真实出现、而手写 JSON 容易漏掉的形状:
* - 服务端排序是 `pinned DESC, type='main' DESC, created_at ASC`,
* **置顶的次级会话合法地排在主会话前面**(不能假设下标 0 是主会话);
* - 还没有用户消息的会话 `title` 键**整个缺席**(自动命名只在首条用户消息时写入);
* - 没有任何消息的会话 `lastMessage` 键整个缺席;
* - `pinned` 恒下发(主会话上是显式 `false`),`unreadCount` 无关键照收。
*/
class SessionDtoRealPayloadTest {
private val json = ApiClientFactory.json
private val sessionList = ListSerializer(SessionSummaryDto.serializer())
// —— 以下是真实响应体(未做任何裁剪) ——
/** `GET /api/v1/sessions`:被置顶的次级会话在主会话**之前**。 */
private val realSessionList = """
{"data":[{"id":"web:299bbf0b4e6a45d7acecf4e848665f14","type":"sub","title":"Renamed by PATCH","createdAt":1791092728237,"pinned":true,"unreadCount":0},{"id":"web","type":"main","createdAt":1788724031196,"pinned":false,"lastMessage":{"role":"assistant","content":"\u003cp\u003e\u003caction\u003e愣了一下,随即弯起眼睛,笑得比平时还软\u003c/action\u003e\u003c/p\u003e \u003cp\u003e原来人家能一直好好待在这里…","createdAt":1791090628688},"unreadCount":6}]}
""".trimIndent()
/** `PATCH /api/v1/sessions/{id}` 改标题的真实响应:**局部**形状,没有 `pinned`。 */
private val realPatchTitle = """
{"data":{"id":"web:299bbf0b4e6a45d7acecf4e848665f14","title":"Renamed by PATCH"}}
""".trimIndent()
/** 同一次会话改置顶的真实响应:**局部**形状,没有 `title`。 */
private val realPatchPinned = """
{"data":{"id":"web:299bbf0b4e6a45d7acecf4e848665f14","pinned":true}}
""".trimIndent()
private fun decode(body: String): List<SessionSummaryDto> =
json.decodeFromString(ApiEnvelope.serializer(sessionList), body).data!!
private fun decodeUpdate(body: String): UpdateSessionResponse =
json.decodeFromString(ApiEnvelope.serializer(UpdateSessionResponse.serializer()), body).data!!
@Test
fun `a pinned sub session legitimately precedes the main session`() {
val sessions = decode(realSessionList)
assertEquals(2, sessions.size)
// 下标 0 是被置顶的次级会话——「主会话恒首位」是错的。
assertEquals(SessionSummaryDto.TYPE_SUB, sessions[0].type)
assertFalse(sessions[0].isMain)
assertTrue(sessions[0].pinned)
assertEquals("web:299bbf0b4e6a45d7acecf4e848665f14", sessions[0].id)
assertEquals("web", sessions[1].id)
assertTrue(sessions[1].isMain)
// 服务端是顺序的权威:客户端原样渲染,不重排。
}
@Test
fun `a missing title key decodes to null instead of a placeholder`() {
val main = decode(realSessionList)[1]
// 主会话已经有 32 条用户消息却没有标题:自动命名是「首条用户消息」那一刻才写的,
// 之前存下来的会话永远拿不到标题——所以 title 缺失是常态,不是异常。
assertNull(main.title)
}
@Test
fun `a missing lastMessage key decodes to null`() {
val sub = decode(realSessionList)[0]
// 新建但还没发过消息的会话:整个 lastMessage 键缺席。
assertNull(sub.lastMessage)
}
@Test
fun `an explicit false pinned is kept as false`() {
val main = decode(realSessionList)[1]
// 主会话上 pinned 是显式 false(恒下发),不能与「键缺失」混为一谈。
assertFalse(main.pinned)
}
@Test
fun `a present lastMessage carries role content and createdAt`() {
val last = decode(realSessionList)[1].lastMessage!!
assertEquals("assistant", last.role)
assertEquals(1_791_090_628_688L, last.createdAt)
// 服务端只折叠空白 + 截断到 60 字符,**保留原始标记**(HTML 转义解码后才看到标签)。
assertTrue(last.content, last.content.startsWith("<p><action>"))
assertTrue(last.content, last.content.endsWith("…"))
}
@Test
fun `the real patch responses are partial rows, not session rows`() {
val renamed = decodeUpdate(realPatchTitle)
assertEquals("web:299bbf0b4e6a45d7acecf4e848665f14", renamed.id)
assertEquals("Renamed by PATCH", renamed.title)
assertNull(renamed.pinned)
val pinned = decodeUpdate(realPatchPinned)
assertEquals("web:299bbf0b4e6a45d7acecf4e848665f14", pinned.id)
assertEquals(true, pinned.pinned)
assertNull(pinned.title)
}
@Test
fun `a pinned only update omits title on the wire`() {
val body = json.encodeToString(
UpdateSessionRequest.serializer(),
UpdateSessionRequest(pinned = true),
)
// 服务端逐字段用指针判断:title 缺席 = 保留原值。
// 序列化成显式 null 会被 Go 解成「要改的标题是空」——所以这里必须是整键缺席。
assertEquals("""{"pinned":true}""", body)
assertFalse(body, body.contains("title"))
}
@Test
fun `a title only update omits pinned on the wire`() {
val body = json.encodeToString(
UpdateSessionRequest.serializer(),
UpdateSessionRequest(title = "Hello Cyrene"),
)
assertEquals("""{"title":"Hello Cyrene"}""", body)
assertFalse(body, body.contains("pinned"))
}
@Test
fun `an empty title is still serialized, so the blank guard has to be client side`() {
// `explicitNulls=false` 只吞掉 null;空串照样上线,服务端会回 400 `title is required`。
// 挡在输入框那一层(非空 + ≤80 字符)才是唯一防线。
val body = json.encodeToString(
UpdateSessionRequest.serializer(),
UpdateSessionRequest(title = ""),
)
assertEquals("""{"title":""}""", body)
}
@Test
fun `an update with neither field still serializes to an empty object`() {
val body = json.encodeToString(
UpdateSessionRequest.serializer(),
UpdateSessionRequest(),
)
// 服务端对 `{}` 回 400 `title or pinned is required`;调用方必须保证至少带一个字段。
assertEquals("{}", body)
}
}
@@ -0,0 +1,109 @@
package top.yeij.cyrene.ui.sessions
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import top.yeij.cyrene.data.api.dto.SessionLastMessageDto
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
/**
* 列表行的显示规则(纯函数):服务端标题 + 客户端兜底 + 预览文本。
*
* 兜底只发生在「服务端没有标题」时——会话还没有用户消息,`title` 键整个缺席。
* 主会话兜底用「与昔涟」而不是服务端的「主会话」。
*/
class SessionItemTest {
private fun dto(
title: String? = null,
isMain: Boolean = false,
lastMessage: SessionLastMessageDto? = null,
) = SessionSummaryDto(
id = if (isMain) "web" else "web:1",
type = if (isMain) SessionSummaryDto.TYPE_MAIN else SessionSummaryDto.TYPE_SUB,
title = title,
createdAt = 0L,
lastMessage = lastMessage,
)
@Test
fun `a main session without a server title falls back to the app's own label`() {
assertEquals("与昔涟", dto(isMain = true).displayTitle())
assertEquals("与昔涟", dto(title = "", isMain = true).displayTitle())
assertEquals("与昔涟", dto(title = " ", isMain = true).displayTitle())
}
@Test
fun `a sub session without a server title falls back to the generic label`() {
assertEquals("新会话", dto().displayTitle())
assertEquals("新会话", dto(title = "", isMain = false).displayTitle())
assertEquals("新会话", dto(title = "\n\t", isMain = false).displayTitle())
}
@Test
fun `a server title is shown verbatim`() {
assertEquals("Hello Cyrene", dto(title = "Hello Cyrene").displayTitle())
// 服务端自己的主会话兜底是「主会话」:一旦真的存了这个标题就照显示,不再走本端兜底。
assertEquals("主会话", dto(title = "主会话", isMain = true).displayTitle())
// 只有全空白才兜底,带空白但非空白的标题原样显示。
assertEquals(" 两边留白 ", dto(title = " 两边留白 ").displayTitle())
}
@Test
fun `toItem keeps the server order fields and the pin state`() {
val item = dto(title = "Hello Cyrene", lastMessage = null).toItem()
assertEquals("web:1", item.id)
assertEquals("Hello Cyrene", item.title)
assertEquals(false, item.isMain)
assertEquals(false, item.pinned)
assertNull(item.lastMessage)
}
@Test
fun `the preview strips the markup the server keeps in the snippet`() {
val last = SessionLastMessageDto(
role = "assistant",
content = "<p><action>愣了一下</action></p> <p>原来人家能一直好好待在这里…</p>",
createdAt = 0L,
)
// 服务端只折叠空白、截断 60 字,标签原样保留——直接显示会露出 <p><action>。
assertEquals("愣了一下 原来人家能一直好好待在这里…", last.content.toPreviewText())
}
@Test
fun `a truncated snippet does not leak the half open paragraph tag`() {
// 线上真实形状:截断把尾部那个 <p> 的开标签留下了,没有任何闭合标签配它。
val truncated = "<p><action>愣了一下,随即弯起眼睛,笑得比平时还软</action></p>" +
" <p>原来人家能一直好好待在这里…"
assertEquals(
"愣了一下,随即弯起眼睛,笑得比平时还软 原来人家能一直好好待在这里…",
truncated.toPreviewText(),
)
}
@Test
fun `a tag with the same prefix is left alone`() {
// 只剥 p / action / narration,`<pre>` 这类同前缀标签不能被误删。
assertEquals("<pre>x</pre>", "<pre>x</pre>".toPreviewText())
}
@Test
fun `stripping a tag does not glue the text on either side together`() {
// 线上真实形状:旁白紧贴正文。若把标签替成空串,「…了一点」与「今天下午…」会粘成一个词。
val adjacent = "<p>人家轻轻凑近了一点<action>悄悄戳了戳你</action>今天下午的翁法罗斯…</p>"
assertEquals(
"人家轻轻凑近了一点 悄悄戳了戳你 今天下午的翁法罗斯…",
adjacent.toPreviewText(),
)
}
@Test
fun `an empty snippet becomes no summary at all`() {
assertEquals("", " ".toPreviewText())
assertNull(dto(lastMessage = SessionLastMessageDto("assistant", "", 0L)).toItem().lastMessage)
}
}