diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt index 6530f59..cb6ac1e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/SessionPreferences.kt @@ -10,9 +10,7 @@ import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json +import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.dto.SessionSummaryDto private val Context.sessionDataStore: DataStore by preferencesDataStore("cyrene_sessions") @@ -23,23 +21,23 @@ private val Context.sessionDataStore: DataStore by preferencesDataS * @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染 * @param snapshotAt 快照时间(用于「同步于 N 分钟前」) * @param fingerprint `baseUrl + username`;换服务器/账号后快照作废 - * @param order `sessionId -> 显示序号`(后端 title 恒空,序号本地生成) * @param activeSessionId 上次停留的会话 */ data class SessionPrefs( val snapshot: List = emptyList(), val snapshotAt: Long = 0L, val fingerprint: String = "", - val order: Map = emptyMap(), val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID, -) +) { + + /** 指纹匹配才认这份快照——绝不把别的服务器/账号的会话列表拿来用。 */ + fun snapshotFor(fingerprint: String): List = + snapshot.takeIf { this.fingerprint == fingerprint }.orEmpty() +} class SessionPreferences(context: Context) { - private val json = Json { - ignoreUnknownKeys = true - explicitNulls = false - } + private val json = ApiClientFactory.json private val store: DataStore = context.applicationContext.sessionDataStore @@ -48,29 +46,16 @@ class SessionPreferences(context: Context) { snapshot = prefs[KEY_SNAPSHOT].decodeSessions(), snapshotAt = prefs[KEY_SNAPSHOT_AT] ?: 0L, fingerprint = prefs[KEY_FINGERPRINT].orEmpty(), - order = prefs[KEY_ORDER].decodeOrder(), activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID, ) } - /** - * 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵), - * 同时为新出现的次级会话分配显示序号、清理已消失会话的序号。 - */ + /** 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵)。 */ suspend fun saveSnapshot(sessions: List, fingerprint: String) { 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_AT] = System.currentTimeMillis() 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 } } - /** 分配显示序号(当前最大值 + 1);已分配过则原样返回。 */ - 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 退回主会话、同步从快照里剔除。 */ + /** 删除会话:必要时把 active 退回主会话,并同步从快照里剔除。 */ suspend fun removeSession(sessionId: String) { 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) { prefs[KEY_ACTIVE] = SessionSummaryDto.MAIN_SESSION_ID } @@ -111,7 +75,7 @@ class SessionPreferences(context: Context) { } } - /** 登出 / 切换服务器时清空。 */ + /** 登出时清空:快照、指纹、active 一起走,下次登录不会先闪一份旧列表。 */ suspend fun clear() { store.edit { it.clear() } } @@ -120,18 +84,12 @@ class SessionPreferences(context: Context) { ?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() } .orEmpty() - private fun String?.decodeOrder(): Map = this - ?.let { runCatching { json.decodeFromString(OrderSerializer, it) }.getOrNull() } - .orEmpty() - private companion object { val KEY_SNAPSHOT = stringPreferencesKey("sessions_snapshot") val KEY_SNAPSHOT_AT = longPreferencesKey("sessions_snapshot_at") val KEY_FINGERPRINT = stringPreferencesKey("sessions_fingerprint") - val KEY_ORDER = stringPreferencesKey("sessions_order") val KEY_ACTIVE = stringPreferencesKey("active_session_id") val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer()) - val OrderSerializer = MapSerializer(String.serializer(), Int.serializer()) } } 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 41c46ad..9257737 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 @@ -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.HistoryMessageDto 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.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query @@ -28,6 +32,21 @@ interface SessionsApi { @DELETE("api/v1/sessions/{id}") suspend fun deleteSession(@Path("id") sessionId: String): Response> + /** + * 重命名 / 置顶(`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> + /** * 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。 * @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制) 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 c28183a..f0ce62a 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 @@ -4,7 +4,13 @@ import kotlinx.serialization.Serializable /** * `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 data class SessionSummaryDto( @@ -12,6 +18,9 @@ data class SessionSummaryDto( val type: String, val title: String? = null, val createdAt: Long, + val pinned: Boolean = false, + /** 会话还没有消息时**整个键缺席**。 */ + val lastMessage: SessionLastMessageDto? = null, ) { val isMain: Boolean get() = type == TYPE_MAIN || id == MAIN_SESSION_ID @@ -29,6 +38,45 @@ data class CreateSessionResponse( val sessionId: String, ) +/** + * 会话最后一条消息的摘要(服务端 `historyLastMessage`)。 + * + * [content] 已被服务端折叠空白、截断到 60 个字符 + `…`,但**保留原始的 `

` / `` 标记**: + * 直接拿来显示会露出标签,渲染前必须剥掉。 + */ +@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":""}}`。 */ @Serializable data class DeleteSessionResponse( 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 00e505e..7046819 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 @@ -3,12 +3,14 @@ package top.yeij.cyrene.data.repository import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.apiCall import top.yeij.cyrene.data.api.dto.SessionSummaryDto +import top.yeij.cyrene.data.api.dto.UpdateSessionRequest +import top.yeij.cyrene.data.api.dto.UpdateSessionResponse import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** - * 会话列表 / 新建 / 删除的数据获取层。 + * 会话列表 / 新建 / 重命名置顶 / 删除的数据获取层。 * * 列表策略(§3.1):调用方拿到结果后**整体替换**本地快照,不做 merge; * 失败时调用方保留旧快照。仓库本身不持有任何持久化状态。 @@ -18,7 +20,10 @@ class SessionRepository( private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) { - /** `GET /sessions`,原样返回服务端顺序(主会话恒首位)。 */ + /** + * `GET /sessions`,原样返回服务端顺序——**不做客户端排序**: + * `pinned DESC, type='main' DESC, created_at ASC`,置顶的次级会话合法地排在主会话前面。 + */ suspend fun listSessions(): Result> = withContext(dispatcher) { apiCall { api.listSessions() } } @@ -28,6 +33,18 @@ class SessionRepository( apiCall { api.createSession() }.map { it.sessionId } } + /** + * `PATCH /sessions/{id}`:只发本次要改的字段,缺席的字段服务端保持原值 + * (所以 `null` 是「不改」,不是「清空」——服务端也无法把标题清回自动命名)。 + */ + suspend fun updateSession( + sessionId: String, + title: String? = null, + pinned: Boolean? = null, + ): Result = withContext(dispatcher) { + apiCall { api.updateSession(sessionId, UpdateSessionRequest(title = title, pinned = pinned)) } + } + /** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */ suspend fun deleteSession(sessionId: String): Result = withContext(dispatcher) { apiCall { api.deleteSession(sessionId) }.map { it.deleted } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt index 6c8af5a..8bd7cb4 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt @@ -52,12 +52,20 @@ fun ChatHomeScreen( else -> MiuixDrawer( open = drawerOpen, - onOpenChange = { drawerOpen = it }, + // 抽屉只会被外部入口打开(MiuixDrawer 的 onOpenChange 只回报关闭), + // 所以这里不用管「打开」分支。 + onOpenChange = { open -> drawerOpen = open }, content = { ChatScreen( container = container, sessionId = state.activeSessionId, - onOpenDrawer = { drawerOpen = true }, + onOpenDrawer = { + drawerOpen = true + // 抽屉打开时静默重拉:列表只在 init / 新建 / 删除时取过,而服务端的 + // 自动标题与置顶顺序**没有任何 WS 帧**会推过来,只能靠这里对齐。 + // 静默是为了不闪「同步中…」——屏幕上本来就有内容。 + sessionsViewModel.refresh(silent = true) + }, onLogout = onLogout, ) }, @@ -70,6 +78,8 @@ fun ChatHomeScreen( }, onCreate = sessionsViewModel::createSession, onDelete = { sessionsViewModel.deleteSession(it.id) }, + onTogglePin = { sessionsViewModel.setPinned(it.id, !it.pinned) }, + onRename = { sessionId, title -> sessionsViewModel.renameSession(sessionId, title) }, onOpenIot = { /* P2:IoT 页面 */ }, onOpenAdmin = { drawerOpen = false 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 e47467f..c630f1e 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 @@ -1,7 +1,7 @@ package top.yeij.cyrene.ui.sessions 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.Column 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.Text 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.extended.Add 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 -/** 侧边栏内容:会话列表 + IoT/设置入口。列表项为 MD3 小圆角卡片。 */ +/** + * 侧边栏内容:会话列表 + IoT/设置入口。列表项为 MD3 小圆角卡片。 + * + * 行内交互:单击切换会话、**长按重命名**、行尾置顶/取消置顶;删除只对次级会话开放。 + * 标题是服务端的自动标题(首条用户消息生成),没有就落到兜底标题。 + */ @Composable fun ColumnScope.SessionDrawerContent( state: SessionsUiState, onSelect: (String) -> Unit, onCreate: () -> Unit, onDelete: (SessionItem) -> Unit, + onTogglePin: (SessionItem) -> Unit, + onRename: (String, String) -> Unit, onOpenIot: () -> Unit, onOpenAdmin: () -> Unit, onOpenSettings: () -> Unit, ) { var pendingDelete by remember { mutableStateOf(null) } + var pendingRename by remember { mutableStateOf(null) } Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) { Text("Cyrene", style = MiuixTheme.textStyles.title2) @@ -77,11 +88,22 @@ fun ColumnScope.SessionDrawerContent( session = session, active = session.id == state.activeSessionId, onClick = { onSelect(session.id) }, + onTogglePin = { onTogglePin(session) }, + onRename = { pendingRename = 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) { Text( text = "网络异常,显示 ${relativeTime(state.syncedAt)} 的结果", @@ -118,19 +140,38 @@ fun ColumnScope.SessionDrawerContent( 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 private fun SessionRow( session: SessionItem, active: Boolean, onClick: () -> Unit, + onTogglePin: () -> Unit, + onRename: () -> Unit, onDelete: () -> Unit, ) { PreferenceCard { BasicComponent( + modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onRename), title = session.title, - onClick = onClick, + summary = session.lastMessage, endActions = { if (active) { Text( @@ -140,6 +181,17 @@ private fun SessionRow( 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) { IconButton(onClick = onDelete) { 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 private fun ConfirmDialog( 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 { val minutes = (System.currentTimeMillis() - timestamp) / 60_000 return when { 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 5fac9bd..08c91a0 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 @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch 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.dto.SessionSummaryDto @@ -16,6 +17,9 @@ data class SessionItem( val id: String, val title: String, val isMain: Boolean, + val pinned: Boolean, + /** 最后一条消息的一行摘要;会话还没有消息时为 null。 */ + val lastMessage: String?, ) data class SessionsUiState( @@ -30,7 +34,9 @@ data class SessionsUiState( /** * 会话列表(§3.1): - * 启动先用本地快照渲染,再拉服务端;成功则整体替换并回写快照,失败则保留快照并提示。 + * 启动先用本地快照渲染(指纹不匹配就不用),再拉服务端;成功则整体替换并回写快照, + * 失败则保留快照并提示。顺序完全信任服务端(`pinned DESC, main DESC, created_at ASC`), + * 客户端不重排。 */ class SessionsViewModel(private val container: AppContainer) : ViewModel() { @@ -41,29 +47,51 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { init { viewModelScope.launch { + val fingerprint = fingerprint() val cached = prefs.data.first() + val snapshot = cached.snapshotFor(fingerprint) _state.update { it.copy( - sessions = cached.snapshot.map { dto -> dto.toItem(cached.order) }, - activeSessionId = cached.activeSessionId, - fromCache = cached.snapshot.isNotEmpty(), - syncedAt = cached.snapshotAt, + sessions = snapshot.map { dto -> dto.toItem() }, + activeSessionId = if (snapshot.isEmpty()) { + SessionSummaryDto.MAIN_SESSION_ID + } else { + cached.activeSessionId + }, + fromCache = snapshot.isNotEmpty(), + syncedAt = if (snapshot.isEmpty()) 0L else cached.snapshotAt, ) } 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 { - _state.update { it.copy(loading = true) } + if (!silent) _state.update { it.copy(loading = true) } container.sessionRepository.listSessions() .onSuccess { list -> prefs.saveSnapshot(list, fingerprint()) val cached = prefs.data.first() _state.update { it.copy( - sessions = list.map { dto -> dto.toItem(cached.order) }, + sessions = list.map { dto -> dto.toItem() }, loading = false, fromCache = false, 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) } private fun fingerprint(): String = "${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}" } -private fun SessionSummaryDto.toItem(order: Map): SessionItem = SessionItem( +internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem( id = id, - title = if (isMain) "与昔涟" else "会话 ${order[id] ?: "?"}", + title = displayTitle(), 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 字,`

` / `` / `` + * 标记**原样保留**(截断还可能把标签截在半路),直接显示会露出标签,所以这里剥掉标签、折成一行。 + * + * 注意标签替成**空格**而不是空串:旁白紧贴正文时(`…凑近了一点悄悄戳了戳你今天下午…`), + * 替成空串会把两侧的字粘成一个词。 + */ +internal fun String.toPreviewText(): String = replace(PREVIEW_TAG_RE, " ") + .replace(WHITESPACE_RE, " ") + .trim() + +/** 只剥本应用认识的三种标记;`\b` 保证 `

` 这类同前缀标签不会被误伤。 */
+private val PREVIEW_TAG_RE = Regex("]*>")
+
+private val WHITESPACE_RE = Regex("\\s+")
+
 private fun Throwable.userMessage(): String =
     (this as? CyreneException)?.error?.userMessage ?: "操作失败,请稍后重试"
diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/prefs/SessionPrefsTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/prefs/SessionPrefsTest.kt
new file mode 100644
index 0000000..5228774
--- /dev/null
+++ b/app/src/test/kotlin/top/yeij/cyrene/core/prefs/SessionPrefsTest.kt
@@ -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(), prefs.snapshotFor("http://host:10942/|someone-else"))
+        assertEquals(emptyList(), prefs.snapshotFor("http://other-host:10942/|testuser"))
+        // 老安装里没有指纹(或指纹恰好为空的极端情况):同样不认。
+        assertEquals(emptyList(), prefs.snapshotFor(""))
+    }
+
+    @Test
+    fun `no snapshot yet stays empty even for the matching fingerprint`() {
+        assertEquals(
+            emptyList(),
+            SessionPrefs(fingerprint = fingerprint).snapshotFor(fingerprint),
+        )
+        assertEquals(emptyList(), SessionPrefs().snapshotFor(""))
+    }
+}
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
new file mode 100644
index 0000000..17baac2
--- /dev/null
+++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/SessionDtoRealPayloadTest.kt
@@ -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 =
+        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("

")) + 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) + } +} 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 new file mode 100644 index 0000000..4369f23 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionItemTest.kt @@ -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 = "

愣了一下

原来人家能一直好好待在这里…

", + createdAt = 0L, + ) + + // 服务端只折叠空白、截断 60 字,标签原样保留——直接显示会露出

。 + assertEquals("愣了一下 原来人家能一直好好待在这里…", last.content.toPreviewText()) + } + + @Test + fun `a truncated snippet does not leak the half open paragraph tag`() { + // 线上真实形状:截断把尾部那个

的开标签留下了,没有任何闭合标签配它。 + val truncated = "

愣了一下,随即弯起眼睛,笑得比平时还软

" + + "

原来人家能一直好好待在这里…" + + assertEquals( + "愣了一下,随即弯起眼睛,笑得比平时还软 原来人家能一直好好待在这里…", + truncated.toPreviewText(), + ) + } + + @Test + fun `a tag with the same prefix is left alone`() { + // 只剥 p / action / narration,`

` 这类同前缀标签不能被误删。
+        assertEquals("
x
", "
x
".toPreviewText()) + } + + @Test + fun `stripping a tag does not glue the text on either side together`() { + // 线上真实形状:旁白紧贴正文。若把标签替成空串,「…了一点」与「今天下午…」会粘成一个词。 + val adjacent = "

人家轻轻凑近了一点悄悄戳了戳你今天下午的翁法罗斯…

" + + assertEquals( + "人家轻轻凑近了一点 悄悄戳了戳你 今天下午的翁法罗斯…", + adjacent.toPreviewText(), + ) + } + + @Test + fun `an empty snippet becomes no summary at all`() { + assertEquals("", " ".toPreviewText()) + assertNull(dto(lastMessage = SessionLastMessageDto("assistant", "", 0L)).toItem().lastMessage) + } +}