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:
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user