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
@@ -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<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)返回。
|
||||
* @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)。
|
||||
* 主会话 `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 个字符 + `…`,但**保留原始的 `<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>"}}`。 */
|
||||
@Serializable
|
||||
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.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<List<SessionSummaryDto>> = 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<UpdateSessionResponse> = withContext(dispatcher) {
|
||||
apiCall { api.updateSession(sessionId, UpdateSessionRequest(title = title, pinned = pinned)) }
|
||||
}
|
||||
|
||||
/** `DELETE /sessions/{id}`,成功返回被删除的会话 id;主会话会以 403 失败。 */
|
||||
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
|
||||
apiCall { api.deleteSession(sessionId) }.map { it.deleted }
|
||||
|
||||
Reference in new issue
Block a user