From 351891714af8eb9e327f2fa5fa06fc91a4378a98 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 21:25:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=8D=95=E6=9D=A1=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E7=9A=84=E7=BC=96=E8=BE=91=E4=B8=8E=E5=88=A0=E9=99=A4?= =?UTF-8?q?=EF=BC=88D3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 契约 §1 行 6a/6b 的两个端点与 §3.8 的两帧,整条链接完(不含「重发」——契约 明确没有重发端点,客户端用编辑后的内容再 POST /chat 即可,本模块不做)。 数据层 - SessionsApi 增 PATCH / DELETE api/v1/messages/{id}(与既有消息端点同源,因此 MessageRepository 能直接复用、不必动 AppContainer),MessageRepository 增 editMessage / deleteMessage,走既有 apiCall / Result 风格。 - 响应形状照契约不照旧文:PATCH 的 data 直接就是消息 DTO(没有 message 包一层), DELETE 回 {deleted: id};编辑内容按「非空、≤4000 rune(码点,不是 length)」 预校验,不合格一个请求都不发。 帧解析 - WsEvent 增 message_edited / message_deleted,形态照 message_persisted 那一套: 会话归属先取帧壳、其次取消息自身的 sessionId,未知键安全、未知帧与坏帧安静忽略、 永不抛。 - message_edited 的 data 就是消息本身(与 message_persisted 的 data.message 不同形), 照抄那一套会解出 null——这一点由单测钉住。 VM - 按 id 原位改 content / 原位移除。两条路各自只有一个落地出口:本端 PATCH/DELETE 的 成功回执与随后到达的广播都调同一对纯函数,而它们幂等(同一内容做两次返回同一实例) ⇒ 「响应 + 广播」双到达收敛成一次,不需要去重表、也不会出现两套状态源。 - 编辑只对 role == user 开放、删除对 user 与 assistant 都开放,判据是同一份纯函数, 气泡(隐藏入口)与 VM(再挡一次)共用。 - 失败一律如实:弹层还开着就留在弹层里(输入不丢、可原地重试),弹层已被关掉就挂到 页面顶部那行横幅,绝不静默。 UI - 长按气泡打开动作菜单;由于正文包在 SelectionContainer 里、长按正文同时也是选字手势 (两者抢同一个长按,谁先消费由运行时决定),另给一枚看得见的「⋯」 (content-desc 消息操作)作为同一条入口,避免只有知道的人才会用。 - 编辑弹层:原始 content 作初值 + 码点计数 + 取消/保存;删除必须二次确认。 - ChatScreen 只加 24 行接线(两个回调 + 两个弹层的渲染)。 测试 +49(766 → 815,全绿):帧解析 14、纯逻辑 15、Retrofit 层 7、失败文案与落点 13。 --- .../top/yeij/cyrene/data/api/SessionsApi.kt | 34 +++ .../yeij/cyrene/data/api/dto/MessageDto.kt | 26 ++ .../cyrene/data/repository/MessageEdits.kt | 123 ++++++++ .../data/repository/MessageRepository.kt | 52 +++- .../kotlin/top/yeij/cyrene/data/ws/WsEvent.kt | 72 +++++ .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 24 ++ .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 276 ++++++++++++++++++ .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 133 ++++++++- .../yeij/cyrene/ui/chat/MessageEditDialog.kt | 221 ++++++++++++++ .../repository/MessageEditRepositoryTest.kt | 169 +++++++++++ .../data/repository/MessageEditsTest.kt | 230 +++++++++++++++ .../cyrene/data/ws/WsEventMessageEditTest.kt | 191 ++++++++++++ .../ui/chat/MessageActionFailureTest.kt | 181 ++++++++++++ 13 files changed, 1717 insertions(+), 15 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageEdits.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageEditDialog.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageEditRepositoryTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageEditsTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventMessageEditTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageActionFailureTest.kt 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 5ccae3a..e455ede 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 @@ -2,11 +2,13 @@ package top.yeij.cyrene.data.api import top.yeij.cyrene.data.api.dto.CreateSessionRequest import top.yeij.cyrene.data.api.dto.CreateSessionResponse +import top.yeij.cyrene.data.api.dto.DeleteMessageResponse import top.yeij.cyrene.data.api.dto.DeleteSessionResponse import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.data.api.dto.ReadSessionRequest import top.yeij.cyrene.data.api.dto.ReadSessionResponse import top.yeij.cyrene.data.api.dto.SessionSummaryDto +import top.yeij.cyrene.data.api.dto.UpdateMessageRequest import top.yeij.cyrene.data.api.dto.UpdateSessionRequest import top.yeij.cyrene.data.api.dto.UpdateSessionResponse import retrofit2.Response @@ -102,4 +104,36 @@ interface SessionsApi { @Path("id") sessionId: String, @Body body: ReadSessionRequest, ): Response> + + /** + * 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,body `{content}`。 + * + * 响应是 `{data:<消息DTO>}`——`data` **直接就是**更新后的完整消息 DTO,**没有** `message` + * 包一层(v0.30 变更记录里那句 `{data:{message}}` 已作废,以契约正文为准)。服务端只改 + * `content`,其余字段一律原值返回。 + * + * 失败面:`content` 空 / 纯空白 / 超 4000 rune → `400 bad_request`;消息不存在 → + * `404 not_found`;未注入 HistoryStore → `501`;权限不足 → `403`。 + * + * ⚠️ 成功后服务端**还会广播** `message_edited`(§3.8)——发起的这条连接自己也会收到它。 + * 于是「REST 响应」与「广播帧」会把同一件事说两遍,两处必须收敛成一次状态变更 + * (见 [top.yeij.cyrene.data.repository.applyMessageEdit])。 + */ + @PATCH("api/v1/messages/{id}") + suspend fun updateMessage( + @Path("id") messageId: String, + @Body body: UpdateMessageRequest, + ): Response> + + /** + * 删除单条消息(D3,契约 §1 行 6b):`DELETE /api/v1/messages/{id}` → + * `{data:{deleted:<消息 id>}}`。 + * + * 失败面:消息不存在 → `404 not_found`;未注入 HistoryStore → `501`;权限不足 → `403`。 + * 成功后同样会广播 `message_deleted`(§3.8,只带被删消息的 `id`)。 + */ + @DELETE("api/v1/messages/{id}") + suspend fun deleteMessage( + @Path("id") messageId: String, + ): Response> } diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt index 134f2f3..19e6107 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt @@ -44,3 +44,29 @@ data class HistoryMessageDto( val documents: List = emptyList(), val createdAt: Long, ) + +/** + * `PATCH /api/v1/messages/{id}` 的请求体(D3,契约 §1 行 6a)。 + * + * **只有 `content` 一个键**:服务端按它改 `content`,role / 时间戳 / 平台 / 图片 / 附件一律保持原值, + * 所以这里**不能**多带任何字段(多带也不会被采纳,只会让后来的读者以为客户端能改这些)。 + * + * 服务端口径:非空、≤ **4000 rune**;空 / 纯空白 / 超长回 `400 bad_request`。 + * [content] 要发**用户原样输入的内容**(含首尾空白)——服务端存的就是这个字符串, + * 客户端替它 trim 一次等于悄悄改掉用户的消息。 + */ +@Serializable +data class UpdateMessageRequest( + val content: String, +) + +/** + * `DELETE /api/v1/messages/{id}` → `200 {"data":{"deleted":"<消息 id>"}}`(契约 §1 行 6b)。 + * + * 与 [DeleteSessionResponse] 逐字同形,但**刻意分开**:这两个 id 的命名空间和「删掉了什么」 + * 是两件事,共用一个类型会让调用方以为删的是会话。 + */ +@Serializable +data class DeleteMessageResponse( + val deleted: String, +) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageEdits.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageEdits.kt new file mode 100644 index 0000000..8e9ec6a --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageEdits.kt @@ -0,0 +1,123 @@ +package top.yeij.cyrene.data.repository + +import top.yeij.cyrene.data.api.dto.HistoryMessageDto + +/** + * 编辑内容的上限(契约 §1 行 6a:非空、≤ **4000 rune**)。 + * + * 「rune」是**码点**而不是 `String.length`:后者数的是 UTF-16 码元,emoji 与绝大多数中文扩展字符 + * 会被算成两个。按 `length` 卡上限会在边界上**误伤**(客户端以为超了、不发;服务端其实收得下), + * 也会**误放**(客户端以为没超、发出去被 400)。 + */ +const val MESSAGE_EDIT_MAX_RUNES = 4000 + +/** + * 一次编辑提交的准入判定(纯函数的结果,见 [validateMessageEdit])。 + * + * 分成三档是因为**用户要做的动作不同**:空了要接着打字,超长了要删字,只有 [Accepted] 才该发请求。 + */ +sealed interface MessageEditDraft { + + /** 可以提交;[content] 就是要发给服务端的字符串(原样,**不 trim**——见 [validateMessageEdit])。 */ + data class Accepted(val content: String) : MessageEditDraft + + /** 空 / 纯空白:服务端会回 `400`,本端**不发这个请求**。 */ + data object Empty : MessageEditDraft + + /** 超过 [max] 个码点:服务端会回 `400`,本端同样不发。 */ + data class TooLong(val runes: Int, val max: Int = MESSAGE_EDIT_MAX_RUNES) : MessageEditDraft +} + +/** + * 校验一次编辑的内容(纯函数)。 + * + * **不 trim**:服务端存的就是用户输入的那个字符串(契约只承诺「非空」),客户端替它去掉首尾空白 + * 等于悄悄改掉用户的消息——对代码块、Markdown 引用这类内容尤其明显。空白只用来判「空」。 + * + * 计数按**码点**([runeCount]),与服务端 Go 的 `utf8.RuneCountInString` 同口径。 + */ +fun validateMessageEdit(raw: String): MessageEditDraft { + if (raw.isBlank()) return MessageEditDraft.Empty + val runes = runeCount(raw) + return if (runes > MESSAGE_EDIT_MAX_RUNES) { + MessageEditDraft.TooLong(runes) + } else { + MessageEditDraft.Accepted(raw) + } +} + +/** 按 Unicode 码点计数:代理对(emoji、部分汉字)算一个,与服务端 rune 口径一致。 */ +fun runeCount(text: String): Int = text.codePointCount(0, text.length) + +/** + * 按 `id` **原位**改一条消息的 `content`(纯函数;编辑落地的唯一实现)。 + * + * 三条刻意的取舍: + * 1. **只改 `content`,不整条替换**。`message_edited` 帧里的 DTO 是完整消息,但它的 `thinking` + * 恒为空(契约 v0.59 §4.4:实时帧一律剥离思维链)。整条替换会把本端已渲染的思维链擦掉, + * 而契约明说这个操作**只改 content**——照它做,就不会有第二种解读。 + * 2. **找不到这个 `id` 就原样返回**,绝不插入。契约 §3.8 写得很直白:「找不到该 id 即忽略, + * 避免把别的会话的消息注入当前列表」。别人的会话里那条消息不该在这里冒出来。 + * 3. **空内容是坏帧,不落**。服务端拒空内容(400),合法的编辑结果不可能为空;真收到空白就把 + * 气泡擦成空白,是拿坏帧毁掉一条好消息。宁可整帧忽略。 + * + * **幂等**:[content] 与现值相同时返回**同一个实例**(Compose 的 key 稳定性靠它),因此 + * 「REST 响应先到、广播后到」(或反过来)连做两次与做一次完全等价——这就是双到达收敛的根据。 + * 不修改入参。 + */ +fun applyMessageEdit( + messages: List, + id: String, + content: String, +): List { + if (id.isBlank() || content.isBlank()) return messages + var changed = false + val updated = messages.map { message -> + if (message.id != id || message.content == content) { + message + } else { + changed = true + message.copy(content = content) + } + } + return if (changed) updated else messages +} + +/** + * 按 `id` 移除一条消息(纯函数;删除落地的唯一实现)。 + * + * `id` 为空白即原样返回:`message_deleted` 的载荷是 `{id}`,坏帧(缺键)解出空串, + * 而「删掉所有 id 为空的消息」不是任何人想要的结果。找不到该 `id` 同样原样返回(幂等)—— + * 「我们自己的 DELETE 回执 + 广播」两次到达、以及别的端删掉一条本端还没加载的消息,都走这里。 + * + * 删除的**路由**由帧壳的 `sessionId` 负责(见 + * [top.yeij.cyrene.data.ws.isForSession]);`sessionId` 缺席时契约允许按**全局唯一**的 `id` + * 兜底——那正是这个函数天然的行为:别的会话的列表里根本不存在这个 `id`。不修改入参。 + */ +fun removeMessageById(messages: List, id: String): List { + if (id.isBlank()) return messages + // 没有这一条就原样返回**同一个实例**(幂等的可观察形式:连做两次与做一次完全等价, + // 也不会白分配一个新列表)。 + if (messages.none { it.id == id }) return messages + return messages.filterNot { it.id == id } +} + +/** + * 这条消息**能不能编辑**(产品口径,UI 与 VM 共用同一份判据)。 + * + * 只放 `user`:assistant 的回复是她说过的话的历史记录,改它等于伪造她说过什么;而要「重发」 + * 一条说错的回复,正确的动作是删掉它再问一遍(契约也明确没有重发端点)。 + * + * 用 `== "user"` 而不是「非 assistant」:`role` 在后端是**开放集合**,将来多出一个角色时 + * fail-closed(不改)比「默认都能改」安全。 + */ +fun canEditMessage(message: HistoryMessageDto): Boolean = message.role == "user" + +/** + * 这条消息**能不能删除**(产品口径,UI 与 VM 共用同一份判据)。 + * + * user 与 assistant 都放:删掉一句错答是常见需求,而且服务端对这个端点本就不分角色。 + * 未知 `role` 一律不放(fail-closed)——不认识的东西不主动去删。 + */ +fun canDeleteMessage(message: HistoryMessageDto): Boolean = + message.role == "user" || message.role == "assistant" diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt index 8879f85..75c84d7 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MessageRepository.kt @@ -1,15 +1,18 @@ package top.yeij.cyrene.data.repository +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.CyreneException import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.apiCall import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.data.api.dto.UpdateMessageRequest import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** - * 会话消息分页获取层。服务端按倒序返回,这里统一转成升序的 [PagedMessages]。 - * 去重 / 合并请用 [mergeMessages]。 + * 会话消息层:分页 / 搜索**读取**,以及单条消息的**编辑 / 删除**(D3)。 + * 服务端按倒序返回分页结果,这里统一转成升序的 [PagedMessages]。去重 / 合并请用 [mergeMessages]。 */ class MessageRepository( private val api: SessionsApi, @@ -66,6 +69,51 @@ class MessageRepository( .map { raw -> toPagedMessages(raw, pageSize) } } + /** + * 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的 + * 完整消息 DTO**。 + * + * 校验在**发请求之前**做([validateMessageEdit]):空 / 纯空白 / 超 4000 rune 的内容服务端 + * 一定回 `400`,明知会被拒还发一次只是白等一个来回。被拒时返回的失败**带得出原因**—— + * 调用方要把「为什么没提交」如实说出来,不能静默什么都不发生。 + * + * 返回的是服务端那一份而不是本地改过的副本:调用方据此更新界面([applyMessageEdit]), + * 与随后到达的 `message_edited` 广播收敛成同一次变更。 + */ + suspend fun editMessage(messageId: String, content: String): Result { + val draft = when (val validation = validateMessageEdit(content)) { + MessageEditDraft.Empty -> return Result.failure( + CyreneException(CyreneError.Server("bad_request", "消息内容不能为空", 400)), + ) + + is MessageEditDraft.TooLong -> return Result.failure( + CyreneException( + CyreneError.Server( + "bad_request", + "消息内容不能超过 ${validation.max} 个字符(当前 ${validation.runes})", + 400, + ), + ), + ) + + is MessageEditDraft.Accepted -> validation.content + } + return withContext(dispatcher) { + apiCall { api.updateMessage(messageId, UpdateMessageRequest(content = draft)) } + } + } + + /** + * 删除单条消息(D3,契约 §1 行 6b):`DELETE /api/v1/messages/{id}`,成功返回被删消息的 `id`。 + * + * 没有本地校验可做(只有一个路径参数),所以失败一律来自服务端:`404 not_found`(这条已经 + * 不在服务端了,常见于别的端先删掉)、`403`(权限)、`501`(没接 HistoryStore)、网络。 + * 调用方要把这些如实说出来——**静默失败会让人以为删掉了,而她下一条回复里那条还在**。 + */ + suspend fun deleteMessage(messageId: String): Result = withContext(dispatcher) { + apiCall { api.deleteMessage(messageId) }.map { it.deleted } + } + companion object { const val DEFAULT_PAGE_SIZE = 50 const val MAX_PAGE_SIZE = 200 diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt index e52746c..ff5b766 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt @@ -367,6 +367,52 @@ sealed class WsEvent { override val type: String get() = "channel_persisted" } + /** + * 一条消息被编辑(D3,契约 §3.8,api-client 上行端点见 §1 行 6a)。 + * + * `data` 是**编辑后的完整消息 DTO**(与 [MessagePersisted] 同形,含 `id` / `content` / + * `sessionId` / `createdAt`,另带 `role` / `mode?` / `mood?` / `image?` / `platform?` / + * `documents`),`thinking` 已按契约剥离。 + * + * 三处要害: + * 1. 它是**全连接广播**(hub 没有按会话订阅),所以会话归属仍要走 [isForSession]; + * 帧壳通常带 `sessionId`,缺席时用 `data` 里消息自己的 `sessionId` 兜底; + * 2. 消费方**按 `id` 原位改 `content`**,找不到忽略(绝不插入——契约 §3.8); + * 3. **本端自己发起的编辑也会收到这一帧**(`PATCH` 成功后服务端照旧广播),于是它与 + * REST 响应是同一件事的两种到达方式,必须收敛成一次(见 + * [top.yeij.cyrene.data.repository.applyMessageEdit] 的幂等性)。 + * + * [message] 为 `null` = 帧里没有可用载荷;此时**整帧跳过**(没有 id 也没有 content, + * 无事可做),而不是编一条空消息出来。 + */ + data class MessageEdited( + val message: HistoryMessageDto?, + override val turnId: String? = null, + override val seq: Long? = null, + override val sessionId: String? = null, + ) : WsEvent() { + override val type: String get() = "message_edited" + } + + /** + * 一条消息被删除(D3,契约 §3.8):`{type:"message_deleted", sessionId, data:{id}}`。 + * + * 载荷**只有** `id`:契约刻意不给整条消息(客户端要做的只是把它拿掉)。会话归属来自帧壳 + * (后端在删除前读一次该消息所属会话填进去);[sessionId] 缺席时按**全局唯一**的 `id` + * 兜底——逐桶搜索只会命中它自己那条,这正是契约允许的兜底方式。 + * + * [id] 空白(坏帧)时上层什么都不做(见 [top.yeij.cyrene.data.repository.removeMessageById]): + * 「删掉所有 id 为空的消息」不是任何人想要的结果。 + */ + data class MessageDeleted( + val id: String, + override val turnId: String? = null, + override val seq: Long? = null, + override val sessionId: String? = null, + ) : WsEvent() { + override val type: String get() = "message_deleted" + } + /** * 指标快照(契约 v0.55 §3.12「可观测实时推送」)。 * @@ -618,6 +664,15 @@ sealed class WsEvent { .let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) } "channel_persisted" -> decode(data) .let { ChannelPersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) } + // 消息编辑(D3 §3.8):`data` **就是**编辑后的完整消息 DTO——**没有** `message` + // 包一层(这与 message_persisted 的 `data:{message:{…}}` 形状**不同**,照抄会解出 null)。 + // 坏帧(缺 data / 结构不对)解出 null:事件照旧成形,上层按「没有可用载荷」整帧跳过。 + "message_edited" -> decodeOrNull(data).let { + MessageEdited(it, turnId, seq, sessionId ?: it?.sessionId?.takeIf(String::isNotBlank)) + } + // 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。 + "message_deleted" -> decode(data) + .let { MessageDeleted(it.id, turnId, seq, sessionId) } "voice_output" -> decode(data) .let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) } "proactive_message" -> decode(data).let { @@ -646,6 +701,16 @@ sealed class WsEvent { private inline fun decode(data: JsonElement?): T = ApiClientFactory.json.decodeFromJsonElement(data ?: JsonObject(emptyMap())) + + /** + * 与 [decode] 同源,但**解不出来就回 `null`**。 + * + * 用在「帧本身还有意义、载荷却可能是坏形状」的地方:[MessageEdited] 的载荷缺失时, + * 事件仍带着帧壳上的 `sessionId` 成形(上层据此整帧跳过),而不是把整帧按坏帧丢掉—— + * 丢掉会让「这一帧来过」这件事完全不可见。 + */ + private inline fun decodeOrNull(data: JsonElement?): T? = + runCatching { decode(data) }.getOrNull() } } @@ -750,6 +815,13 @@ private data class SessionReadData(val readAt: Long = 0L) @Serializable private data class PersistedData(val message: HistoryMessageDto? = null) +/** + * `message_deleted` 载荷(§3.8):`{id}`。缺键 / 空串解成空 `id`,上层据此整帧跳过 + * (见 [top.yeij.cyrene.data.repository.removeMessageById]),不会误删。 + */ +@Serializable +private data class DeletedData(val id: String = "") + /** `observability_trace` 载荷(后端 `gateway.AdminTracePush`):`{spans:[…]}`,最新在前。 */ @Serializable private data class TracePushData(val spans: List = emptyList()) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index ecf36bf..71bea19 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -431,6 +431,11 @@ fun ChatScreen( onImageClick = { raw -> ImageSource.of(raw)?.let { imageTarget = it } }, + // 编辑 / 删除(D3):弹层状态活在 VM(`state.messageAction`), + // 这里只把用户的意图转过去。能不能编辑、能不能删由 VM 与气泡 + // 各自用同一份产品口径的纯函数判定。 + onEdit = viewModel::startEdit, + onDelete = viewModel::startDelete, ) is ChatDisplayItem.Pending -> PendingBubble( @@ -539,6 +544,25 @@ fun ChatScreen( onDismiss = { imageTarget = null }, ) } + + // 单条消息的编辑 / 删除弹层(D3)。状态全在 VM 里:这是**唯一**一处接线, + // 失败也由 VM 决定「留在弹层里说」还是「挂到页面横幅上说」(见 applyMessageActionFailure)。 + state.messageAction?.let { action -> + when (action) { + is MessageActionState.Edit -> MessageEditDialog( + state = action, + onDraftChange = viewModel::onEditDraftChange, + onSubmit = viewModel::submitEdit, + onDismiss = viewModel::cancelMessageAction, + ) + + is MessageActionState.Delete -> MessageDeleteDialog( + state = action, + onConfirm = viewModel::confirmDelete, + onDismiss = viewModel::cancelMessageAction, + ) + } + } } /** diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index f82a659..94eb552 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -21,9 +21,15 @@ import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.data.image.ChatImagePreparer import top.yeij.cyrene.data.image.PreparedImage import top.yeij.cyrene.data.repository.ChatAck +import top.yeij.cyrene.data.repository.MessageEditDraft import top.yeij.cyrene.data.repository.MessageRepository +import top.yeij.cyrene.data.repository.applyMessageEdit +import top.yeij.cyrene.data.repository.canDeleteMessage +import top.yeij.cyrene.data.repository.canEditMessage import top.yeij.cyrene.data.repository.historyForSession import top.yeij.cyrene.data.repository.mergeMessages +import top.yeij.cyrene.data.repository.removeMessageById +import top.yeij.cyrene.data.repository.validateMessageEdit import top.yeij.cyrene.data.ws.ChatSocket import top.yeij.cyrene.data.ws.WsEvent import top.yeij.cyrene.data.ws.WsState @@ -325,6 +331,79 @@ data class StreamingState( val active: Boolean = true, ) +/** 「编辑」与「删除」——[MessageActionState] 的两种形态,文案(失败时要说哪一件事)也靠它区分。 */ +enum class MessageActionKind { Edit, Delete } + +/** + * 单条消息的编辑 / 删除弹层状态(**最多一个**:动作一次只针对一条消息)。 + * + * [inFlight] 是「请求已经发出去了」:此时按钮不该再点第二次(同一个端点没有幂等键, + * 连点两下就是两次 PATCH)。**弹层在请求在飞时可以关掉**——关掉之后失败会落到页面顶部那行 + * 横幅(见 [applyMessageActionFailure]),绝不会因为「弹层没了」而静默消失。 + * + * [error] 只在弹层还开着、且这次失败就是它的时候显示在弹层里(用户就站在那儿,能直接改)。 + */ +sealed interface MessageActionState { + + /** 目标消息 id(两种形态都有)。 */ + val messageId: String + + /** 请求已在路上。 */ + val inFlight: Boolean + + /** 这次动作的失败原因;null = 还没有失败。 */ + val error: String? + + /** + * 编辑弹层。[draft] 初值是**服务端存的原始 `content`**(含 `

` / `` 这类标记)—— + * 编辑的就是那个字符串本身,见 [MessageEditDialog] 的说明。 + */ + data class Edit( + override val messageId: String, + val draft: String, + override val inFlight: Boolean = false, + override val error: String? = null, + ) : MessageActionState + + /** 删除的二次确认弹层(不可恢复,所以必须有这一步)。 */ + data class Delete( + override val messageId: String, + override val inFlight: Boolean = false, + override val error: String? = null, + ) : MessageActionState +} + +/** + * 把一次编辑 / 删除的失败落到状态上(纯函数,VM 与测试共用)。 + * + * 分两种落点,判据是「用户还在不在这件事的现场」: + * - 弹层还开着、且就是这条消息这一次动作 ⇒ **留在弹层里**(改完就能重试,输入不丢); + * - 弹层已经被关掉(用户在等结果时退出了 / 又去操作了别的)⇒ 挂到页面顶部那行 `error` 横幅, + * **绝不静默**:一次没生效的编辑如果什么都不说,用户会以为改成功了。 + */ +fun ChatUiState.applyMessageActionFailure( + kind: MessageActionKind, + messageId: String, + reason: String, +): ChatUiState { + val action = messageAction + val sameAction = when (action) { + is MessageActionState.Edit -> kind == MessageActionKind.Edit && action.messageId == messageId + is MessageActionState.Delete -> kind == MessageActionKind.Delete && action.messageId == messageId + null -> false + } + return if (sameAction) { + val updated = when (action) { + is MessageActionState.Edit -> action.copy(inFlight = false, error = reason) + is MessageActionState.Delete -> action.copy(inFlight = false, error = reason) + null -> return this + } + copy(messageAction = updated) + } else { + copy(error = reason) + } +} + sealed interface ChatDisplayItem { val key: String @@ -381,6 +460,11 @@ data class ChatUiState( * 以及 REST `202 {queued:true,queueDepth:n}` 的受理回执(同一个队列,同一份语义)。 */ val queueDepth: Int = 0, + /** + * 打开着的单条消息动作弹层(编辑 / 删除);null = 没有弹层。同一时刻最多一个—— + * 动作总是针对**一条**消息,两个弹层同时在飞只会让「失败该说给谁听」变得含糊。 + */ + val messageAction: MessageActionState? = null, ) { /** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */ val items: List @@ -421,6 +505,15 @@ data class ChatUiState( * 消费者)——服务端据此判重(`chat_duplicate` / `{duplicate:true}`)而不是落成第二条消息; * 内容改过(哪怕只多一个空格)就铸新键,因为那是另一条消息。**始终没有自动重试**: * 键的复用只发生在用户手动重发的那一刻。 + * + * ## 单条消息的编辑 / 删除(D3) + * 编辑**只对 `user` 消息开放**([canEditMessage]),删除对 `user` 与 `assistant` 都开放 + * ([canDeleteMessage])——判据是同一份纯函数,UI 与这里共用,不存在两套口径。 + * + * 两条路各自**只有一个落地出口**([applyMessageEdited] / [applyMessageDeleted]):本端自己发起的 + * `PATCH` / `DELETE` 成功回执,与随后到达的 `message_edited` / `message_deleted` 广播,都调它们; + * 底层纯函数按 `id` 原位改 / 移除、**幂等**(同一内容做两次等于一次),所以「响应 + 广播」 + * 双到达收敛成一次状态变更——既不需要去重表,也不会出现两套状态源。 */ class ChatViewModel( private val container: AppContainer, @@ -522,6 +615,145 @@ class ChatViewModel( fun clearDraftDocuments() = _state.update { it.copy(draftDocuments = emptyList()) } + // —— D3:单条消息的编辑 / 删除 —— + + /** + * 打开编辑弹层。**只对 `user` 消息开放**(口径见 [canEditMessage],UI 隐藏入口 + 这里再挡一次, + * 判据是同一份纯函数)。 + * + * 初值是**服务端存的原始 `content`**(可能带 `

` / `` 这类标记)——那是唯一真源, + * 编辑它才是「改这条消息」;先剥标记再回写会静默丢掉旁白/动作。 + */ + fun startEdit(message: HistoryMessageDto) { + if (!canEditMessage(message)) return + _state.update { + it.copy(messageAction = MessageActionState.Edit(message.id, draft = message.content)) + } + } + + /** 打开删除的二次确认弹层(`user` 与 `assistant` 都开放,见 [canDeleteMessage])。 */ + fun startDelete(message: HistoryMessageDto) { + if (!canDeleteMessage(message)) return + _state.update { it.copy(messageAction = MessageActionState.Delete(message.id)) } + } + + fun onEditDraftChange(text: String) = _state.update { state -> + val action = state.messageAction as? MessageActionState.Edit ?: return@update state + state.copy(messageAction = action.copy(draft = text, error = null)) + } + + /** + * 关掉弹层(取消 / 返回键 / 点弹层外部)。 + * + * **请求已经在飞时也允许关**:它已经在路上、结果一定会落地(成功照样改这条消息), + * 而失败会落到页面顶部那行横幅([applyMessageActionFailure])——所以关掉弹层 + * 不会让任何一件事悄悄消失。 + */ + fun cancelMessageAction() = _state.update { it.copy(messageAction = null) } + + /** + * 提交编辑:先校验([validateMessageEdit],空 / 超 4000 rune 的服务端必拒,本端不发这一趟), + * 再 `PATCH /api/v1/messages/{id}`。 + * + * 成功与失败都不在这里直接改消息列表:成功走 [applyMessageEdited](与广播同一条出口), + * 失败走 [applyMessageActionFailure](弹层还开着就留在弹层里,用户改完能直接重试)。 + */ + fun submitEdit() { + val action = _state.value.messageAction as? MessageActionState.Edit ?: return + // 同一个端点没有幂等键,连点两下就是两次 PATCH——在飞时不再受理第二次。 + if (action.inFlight) return + val repo = repository ?: return + when (val validation = validateMessageEdit(action.draft)) { + MessageEditDraft.Empty -> { + actionFailed(MessageActionKind.Edit, action.messageId, "消息内容不能为空") + } + + is MessageEditDraft.TooLong -> { + actionFailed( + MessageActionKind.Edit, + action.messageId, + "消息内容不能超过 ${validation.max} 个字符(当前 ${validation.runes})", + ) + } + + is MessageEditDraft.Accepted -> { + _state.update { + it.copy(messageAction = action.copy(inFlight = true, error = null)) + } + viewModelScope.launch { + repo.editMessage(action.messageId, validation.content) + // 服务端那一份才是真相(它可能对内容另作处理),本地不复刻它的规则。 + .onSuccess { updated -> applyMessageEdited(updated.id, updated.content) } + .onFailure { e -> + actionFailed( + MessageActionKind.Edit, + action.messageId, + messageActionFailure(e, MessageActionKind.Edit), + ) + } + } + } + } + } + + /** 二次确认之后真的删:`DELETE /api/v1/messages/{id}`,落地走 [applyMessageDeleted]。 */ + fun confirmDelete() { + val action = _state.value.messageAction as? MessageActionState.Delete ?: return + if (action.inFlight) return + val repo = repository ?: return + _state.update { it.copy(messageAction = action.copy(inFlight = true, error = null)) } + viewModelScope.launch { + repo.deleteMessage(action.messageId) + .onSuccess { applyMessageDeleted(action.messageId) } + .onFailure { e -> + actionFailed( + MessageActionKind.Delete, + action.messageId, + messageActionFailure(e, MessageActionKind.Delete), + ) + } + } + } + + private fun actionFailed(kind: MessageActionKind, messageId: String, reason: String) { + _state.update { it.applyMessageActionFailure(kind, messageId, reason) } + } + + /** + * 编辑落地的**唯一**出口:本端 `PATCH` 的成功回执与 `message_edited` 广播都走这里。 + * + * 幂等由纯函数 [applyMessageEdit] 兜住(同一个内容连做两次与做一次等价),所以 + * 「响应先到、广播后到」不会造成两套状态、也不需要去重表。 + * + * 顺带把**这条消息的**编辑弹层关掉(成功之后没什么可编辑的了);只关它自己的那一层—— + * 用户在等结果时又去编辑了别的消息,那层不许被误关。 + */ + private fun applyMessageEdited(messageId: String, content: String) { + _state.update { state -> + val ownDialog = (state.messageAction as? MessageActionState.Edit)?.messageId == messageId + state.copy( + messages = applyMessageEdit(state.messages, messageId, content), + messageAction = if (ownDialog) null else state.messageAction, + ) + } + } + + /** + * 删除落地的**唯一**出口:本端 `DELETE` 的成功回执与 `message_deleted` 广播都走这里。 + * + * 同样靠纯函数的幂等([removeMessageById] 找不到就原样返回)收敛双到达;关弹层的范围 + * 与 [applyMessageEdited] 一致——只关这条消息的那一层删除确认。 + */ + private fun applyMessageDeleted(messageId: String) { + _state.update { state -> + val ownDialog = (state.messageAction as? MessageActionState.Delete)?.messageId == messageId + state.copy( + messages = removeMessageById(state.messages, messageId), + messageAction = if (ownDialog) null else state.messageAction, + ) + } + } + /** * 发送:先乐观上屏,再按路由决定走哪条上行通道;失败回滚并把文本 / 图片 / 附件还给输入框。 * @@ -1050,6 +1282,14 @@ class ChatViewModel( is WsEvent.MessagePersisted -> onPersisted(event.message) + // D3:编辑 / 删除的实时增量,与本端自己发起时的 REST 回执**走同一条落地路径** + // ([applyMessageEdited] / [applyMessageDeleted]),所以「响应 + 广播」双到达 + // 只是一次状态变更。会话归属由上面的 isForSession 闸收敛(帧壳带 sessionId; + // message_edited 缺席时解析层已用消息自己的 sessionId 兜底)。 + is WsEvent.MessageEdited -> event.message?.let { applyMessageEdited(it.id, it.content) } + + is WsEvent.MessageDeleted -> applyMessageDeleted(event.id) + is WsEvent.SessionBusy -> if (event.sessionId == sessionId) { _state.update { it.copy(busyMode = event.mode) } } @@ -1287,3 +1527,39 @@ fun documentUploadFailureMessage(e: Throwable): String { is DocumentFailure.Other -> "附件上传失败," + (error?.userMessage ?: "请重试") } } + +/** + * 编辑 / 删除失败的文案(纯函数,可单测)。 + * + * 这里**不直接复用** `CyreneError.userMessage`:它的 401 兜底文案是「用户名或密码不正确」, + * 那是登录页的口径;在一次编辑里看到这句话,用户会去改密码而不是重新登录。**每一档失败都对应 + * 一个不同的动作**,所以每一档都得自己说清楚: + * + * - **401**:登录状态失效 ⇒ 重新登录(不是「换一条消息」); + * - **403**:没有权限 ⇒ 这台账号本来就不该能改(RBAC 分档); + * - **404**:这条消息已经不在服务端 ⇒ 常见于别的端先删掉了,刷新即可看到真相; + * - **400**:服务端对内容另有要求(本端已按 4000 rune 预校验,真出现就原样展示服务端的说法, + * 绝不替它编一个数字); + * - **501**:服务端没接 HistoryStore ⇒ 这个部署就不支持改删; + * - 其余(网络 / 超时 / 5xx / 未知):保留既有错误口径,并点明**是哪一步**失败—— + * 「无法连接到服务器」本身不说明前面那句话是没改上还是没删掉。 + * + * 绝不返回空串 / 静默:调用方拿到什么都会如实显示(见 [ChatUiState.applyMessageActionFailure])。 + */ +fun messageActionFailure(e: Throwable, kind: MessageActionKind): String { + val verb = if (kind == MessageActionKind.Edit) "编辑" else "删除" + val error = (e as? CyreneException)?.error + return when { + error is CyreneError.Unauthorized -> "登录状态已失效,请重新登录后再${verb}" + error is CyreneError.Server && error.httpStatus == 403 -> "没有权限${verb}这条消息" + error is CyreneError.Server && (error.httpStatus == 404 || error.code == "not_found") -> + "这条消息在服务端已不存在,可能已被其它端删除" + + error is CyreneError.Server && error.httpStatus == 501 -> + "服务端没有开启消息存储,无法${verb}" + + error is CyreneError.Server -> error.message.ifBlank { "${verb}失败,请重试" } + error != null -> "${verb}失败," + error.userMessage + else -> "${verb}失败,请重试" + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index b558f42..7495a91 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -2,6 +2,7 @@ package top.yeij.cyrene.ui.chat 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.Box import androidx.compose.foundation.layout.Column @@ -10,11 +11,16 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -27,6 +33,8 @@ import coil3.compose.AsyncImage import top.yeij.cyrene.core.prefs.LocalAppPrefs import top.yeij.cyrene.data.api.dto.DocumentRefDto import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.data.repository.canDeleteMessage +import top.yeij.cyrene.data.repository.canEditMessage import top.yeij.cyrene.domain.chat.DocumentChipModel import top.yeij.cyrene.domain.chat.DocumentChips import top.yeij.cyrene.domain.chat.DraftDocument @@ -39,11 +47,27 @@ import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.basic.Close +import top.yukonga.miuix.kmp.icon.extended.More import top.yukonga.miuix.kmp.theme.MiuixTheme /** 流式进行中的光标。 */ private const val CURSOR = "▍" +/** 气泡圆角(正文、图片、附件芯片三处各自的形状里,只有这一处是气泡本体)。 */ +private val BubbleShape = RoundedCornerShape(18.dp) + +/** 角标行里那枚「⋯」的大小;刻意小于 Miuix 的最小交互尺寸,别把每条消息的角标行撑高。 */ +private val MESSAGE_ACTION_ICON_SIZE = 18.dp + +/** 「⋯」与来源角标之间的间距。 */ +private val MESSAGE_ACTION_GAP = 4.dp + +/** 「⋯」的读屏名字,同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */ +private const val MESSAGE_ACTION_DESCRIPTION = "消息操作" + +/** 长按气泡的读屏名字:说清按下去会发生什么(不长按就永远不知道)。 */ +private const val MESSAGE_ACTION_LONG_PRESS_LABEL = "打开这条消息的操作菜单" + /** * 一条落库消息渲染成一个或多个气泡: * - `mode` 为 `chat`(或缺失——后端当前不落库 mode,一律回落 chat)时按 `

` 切分, @@ -55,12 +79,29 @@ private const val CURSOR = "▍" * * 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer], * 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。 + * + * ## 编辑 / 删除的两个入口(D3) + * - **长按气泡**(`combinedClickable`)打开动作菜单; + * - 气泡下方那一行里的 **「⋯」**(content-desc「消息操作」)打开**同一个**菜单。 + * + * 为什么要两个入口:气泡里的正文被 [SelectionContainer] 包着,而**长按正文是「选字」的手势** + * (两者抢同一个长按,谁先消费由运行时决定,不可靠)——只留长按的话,用户最自然会按的那块区域 + * 恰恰可能不响。因此多给一个看得见、点得着的入口,长按照旧保留(气泡内边距、附件芯片、 + * 图片这些不参与选字的区域按下去就是菜单)。 + * + * 哪些动作可用由**产品口径**的纯函数给出([canEditMessage] / [canDeleteMessage]), + * 与 VM 里那道闸是同一份判据;两个都不成立时(未知 role)两个入口都不出现。 + * + * @param onEdit 用户选中「编辑」:交给上层打开编辑弹层(本组件不自己发请求)。 + * @param onDelete 用户选中「删除」:交给上层打开删除确认(不可恢复,必须二次确认)。 */ @Composable fun MessageBubble( message: HistoryMessageDto, onDocumentClick: (DocumentRefDto) -> Unit = {}, onImageClick: (String) -> Unit = {}, + onEdit: (HistoryMessageDto) -> Unit = {}, + onDelete: (HistoryMessageDto) -> Unit = {}, ) { val isUser = message.role == "user" // 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同: @@ -72,7 +113,18 @@ fun MessageBubble( parsed.ifEmpty { listOf(plainBubble(message.content)) } } - MessageGroup(isUser = isUser, footer = messageSourceLabel(message, LocalEnumCatalog.current)) { + val editable = canEditMessage(message) + val deletable = canDeleteMessage(message) + val canAct = editable || deletable + // 每个气泡各自记住菜单开着没有:重建 / 滚动复用不会把上一个气泡的菜单带过来。 + var menuOpen by remember(message.id) { mutableStateOf(false) } + val openMenu = if (canAct) { { menuOpen = true } } else null + + MessageGroup( + isUser = isUser, + footer = messageSourceLabel(message, LocalEnumCatalog.current), + onOpenMenu = openMenu, + ) { message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) } DocumentChipRow( chips = message.documents.map(DocumentChips::of), @@ -81,9 +133,25 @@ fun MessageBubble( ) message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) } bubbles.forEachIndexed { index, bubble -> - Bubble(bubble, isUser, isLast = index == bubbles.lastIndex) + Bubble(bubble, isUser, isLast = index == bubbles.lastIndex, onLongPress = openMenu) } } + + if (menuOpen) { + MessageActionMenu( + editable = editable, + deletable = deletable, + onEdit = { + menuOpen = false + onEdit(message) + }, + onDelete = { + menuOpen = false + onDelete(message) + }, + onDismiss = { menuOpen = false }, + ) + } } /** @@ -250,6 +318,7 @@ fun StreamingBubble(streaming: StreamingState) { private fun MessageGroup( isUser: Boolean, footer: String? = null, + onOpenMenu: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit, ) { Column( @@ -260,14 +329,36 @@ private fun MessageGroup( verticalArrangement = Arrangement.spacedBy(6.dp), ) { content() - // 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下) - if (footer != null) { - Text( - text = footer, - style = MiuixTheme.textStyles.footnote2, - color = MiuixTheme.colorScheme.onSurfaceVariantSummary, - modifier = Modifier.padding(start = 6.dp, end = 6.dp), - ) + // 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)。 + // 「⋯」与它同一行:那枚图标是**看得见的**动作入口(长按同样能开,见 [MessageBubble])。 + if (footer != null || onOpenMenu != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MESSAGE_ACTION_GAP), + ) { + if (footer != null) { + Text( + text = footer, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(start = 6.dp, end = 6.dp), + ) + } + if (onOpenMenu != null) { + // 用 Icon + clickable 而不是 IconButton:后者的最小交互尺寸会把每一条消息的 + // 角标行撑到 48dp,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了 + // (长按气泡那条主路径仍然在)。 + Icon( + imageVector = MiuixIcons.More, + contentDescription = MESSAGE_ACTION_DESCRIPTION, + tint = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier + .size(MESSAGE_ACTION_ICON_SIZE) + .clip(CircleShape) + .clickable(onClick = onOpenMenu), + ) + } + } } } } @@ -278,10 +369,11 @@ private fun Bubble( isUser: Boolean, isLast: Boolean, showCursor: Boolean = false, + onLongPress: (() -> Unit)? = null, ) { AnnotationLine(bubble.head) val body = if (showCursor && isLast && bubble.body.isNotEmpty()) bubble.body + CURSOR else bubble.body - if (body.isNotEmpty()) BubbleBody(body, isUser) + if (body.isNotEmpty()) BubbleBody(body, isUser, onLongPress) AnnotationLine(bubble.tail) } @@ -302,17 +394,32 @@ private fun AnnotationLine(parts: List) { } @Composable -private fun BubbleBody(text: String, isUser: Boolean) { +private fun BubbleBody(text: String, isUser: Boolean, onLongPress: (() -> Unit)? = null) { Box( modifier = Modifier .widthIn(max = 300.dp) + // clip + background 与原来那句 `background(color, shape)` 画出来完全一样, + // 多出来的这一层是为了让长按的涟漪被圆角裁住(否则它会溢出到方角外)。 + .clip(BubbleShape) .background( color = if (isUser) { MiuixTheme.colorScheme.primary } else { MiuixTheme.colorScheme.surfaceContainer }, - shape = RoundedCornerShape(18.dp), + ) + .then( + if (onLongPress == null) { + Modifier + } else { + Modifier.combinedClickable( + onLongClickLabel = MESSAGE_ACTION_LONG_PRESS_LABEL, + onLongClick = onLongPress, + // 短按不放任何功能:气泡上没有「点一下要做的事」,而涟漪仍然给得出 + // 「这块能按」的反馈(长按入口靠它才被发现)。 + onClick = {}, + ) + }, ) .padding(horizontal = 14.dp, vertical = 10.dp), ) { diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageEditDialog.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageEditDialog.kt new file mode 100644 index 0000000..d4fd502 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageEditDialog.kt @@ -0,0 +1,221 @@ +package top.yeij.cyrene.ui.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import top.yeij.cyrene.data.repository.MESSAGE_EDIT_MAX_RUNES +import top.yeij.cyrene.data.repository.runeCount +import top.yeij.cyrene.ui.common.PreferenceCard +import top.yukonga.miuix.kmp.basic.BasicComponent +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.theme.MiuixTheme + +/** + * 一条消息上的动作菜单(长按气泡 / 点气泡旁那枚「⋯」都会打开它)。 + * + * 用 Compose 的 `Dialog` + Miuix 内容,与抽屉里的新建 / 重命名 / 删除确认**同一套做法**: + * 项目里没有 Miuix 的菜单类组件,而「凭空去猜一个没见过的弹层 API」正是 `SessionDrawerContent` + * 当初刻意避开的做法。选项行仍是 `PreferenceCard { BasicComponent(...) }` + * (Miuix 列表项自身没有背景与圆角)。 + * + * 两个动作的可见性由调用方按**同一份判据**给出(`canEditMessage` / `canDeleteMessage`): + * 编辑只对 user 消息出现,删除对 user 与 assistant 都出现。两条都不成立时这个弹层不会被打开。 + * + * 每一行都带一句 summary 说明会发生什么——尤其是「其它端会跟着变」和「不可恢复」这两件 + * 用户看不见的后果。 + */ +@Composable +fun MessageActionMenu( + editable: Boolean, + deletable: Boolean, + onEdit: () -> Unit, + onDelete: () -> Unit, + onDismiss: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp)) + .padding(DIALOG_PADDING), + ) { + Text("这条消息", style = MiuixTheme.textStyles.title3) + Spacer(Modifier.height(12.dp)) + if (editable) { + PreferenceCard { + BasicComponent( + title = "编辑", + summary = "改这条消息的内容,其它端会跟着刷新", + onClick = onEdit, + ) + } + Spacer(Modifier.height(8.dp)) + } + if (deletable) { + PreferenceCard { + BasicComponent( + title = "删除", + summary = "从所有端删除这条消息,不可恢复", + onClick = onDelete, + ) + } + } + Spacer(Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton("取消", onClick = onDismiss) + } + } + } +} + +/** + * 编辑弹层:一个多行输入框 + 码点计数 + 取消 / 保存。 + * + * ## 编辑的是「服务端存的那个字符串」 + * 初值就是消息的原始 `content`。后端的正文里可能有 Web 渲染链路的标记(`

` / `` / + * ``),它们在气泡里被 `domain/parse` 解析成正文与旁白——但**存储里就是一个字符串**, + * 编辑它才是「改这条消息」。先剥标记再回写会静默丢掉旁白与动作(而且丢掉的正是用户没打算删的部分)。 + * 代价是编辑框里会看到标记本身;这是刻意的取舍,不在这里做「只显示纯文本」的假象。 + * + * ## 计数为什么按码点 + * 服务端上限是 4000 **rune**([MESSAGE_EDIT_MAX_RUNES]),所以这里也用 [runeCount]: + * 按 `length` 数会把 emoji 算成两个,边界上既会误伤也会误放。超限时保存键直接不可点, + * 免得用户点了才知道——服务端那 400 一样会如实说出来,但能提前避免就提前避免。 + * + * ## 失败留在弹层里 + * [MessageActionState.Edit.error] 非空时显示在按钮上方:这条消息原地重试即可,输入不丢。 + * (弹层被关掉之后才失败的,落到页面顶部那行横幅,见 [ChatUiState.applyMessageActionFailure]。) + * + * @param state 弹层的草稿 / 进行态 / 失败原因(状态在 VM 里,弹层自己不持有提交结果)。 + */ +@Composable +fun MessageEditDialog( + state: MessageActionState.Edit, + onDraftChange: (String) -> Unit, + onSubmit: () -> Unit, + onDismiss: () -> Unit, +) { + val used = runeCount(state.draft) + val tooLong = used > MESSAGE_EDIT_MAX_RUNES + // 空白内容服务端必拒(400),本端不发这一趟;在飞时也不许再点一次(没有幂等键)。 + val canSave = state.draft.isNotBlank() && !tooLong && !state.inFlight + + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp)) + .padding(DIALOG_PADDING), + ) { + Text("编辑这条消息", style = MiuixTheme.textStyles.title3) + Spacer(Modifier.height(12.dp)) + TextField( + value = state.draft, + onValueChange = onDraftChange, + modifier = Modifier.fillMaxWidth(), + label = "消息内容", + useLabelAsPlaceholder = true, + maxLines = 8, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = "$used / $MESSAGE_EDIT_MAX_RUNES", + style = MiuixTheme.textStyles.footnote2, + color = if (tooLong) { + MiuixTheme.colorScheme.error + } else { + MiuixTheme.colorScheme.onSurfaceVariantSummary + }, + ) + if (state.error != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = state.error, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.error, + ) + } + Spacer(Modifier.height(20.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton("取消", onClick = onDismiss) + Spacer(Modifier.width(8.dp)) + TextButton( + text = if (state.inFlight) "保存中…" else "保存", + onClick = onSubmit, + enabled = canSave, + ) + } + } + } +} + +/** + * 删除的二次确认弹层。 + * + * 与抽屉删会话那一处同形(`ConfirmDialog` 的做法):`Dialog` + Miuix 内容 + + * 标题 / 说明 / 取消 + 确认。这里的说明必须把两件事说清:**影响所有端**、**不可恢复**—— + * 删除是唯一一个没有回头路的动作。 + * + * [MessageActionState.Delete.error] 非空时显示在按钮上方(例如别的端已经先删掉了,回 404): + * 用户看到的是一句能解释原因的话,而不是一个点了没反应的按钮。 + */ +@Composable +fun MessageDeleteDialog( + state: MessageActionState.Delete, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp)) + .padding(DIALOG_PADDING), + ) { + Text("删除这条消息?", style = MiuixTheme.textStyles.title3) + Spacer(Modifier.height(8.dp)) + Text( + text = "它会从所有端移除,且不可恢复。", + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + if (state.error != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = state.error, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.error, + ) + } + Spacer(Modifier.height(20.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton("取消", onClick = onDismiss) + Spacer(Modifier.width(8.dp)) + TextButton( + text = if (state.inFlight) "删除中…" else "删除", + onClick = onConfirm, + enabled = !state.inFlight, + ) + } + } + } +} + +/** + * 三个弹层共用的内边距(与 `NewSessionDialog` / 重命名 / 删除确认一致):四周 20dp。 + * 抽成一处是为了「改一次都跟着改」——它们本来就该长得一样。 + */ +private val DIALOG_PADDING = 20.dp diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageEditRepositoryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageEditRepositoryTest.kt new file mode 100644 index 0000000..3af8e95 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageEditRepositoryTest.kt @@ -0,0 +1,169 @@ +package top.yeij.cyrene.data.repository + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.ApiClientFactory +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.data.api.SessionsApi +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors + +/** + * D3 的**数据层**:`PATCH` / `DELETE /api/v1/messages/{id}` 真的发出去的是什么。 + * + * 用 JDK 自带的 `com.sun.net.httpserver` 跑真 OkHttp + 真 Retrofit(与 `MessageSearchRepositoryTest` + * / `ChatRepositoryTest` 同一条做法,不引新依赖),断言落在**方法 + 原始路径 + 原始请求体**上: + * + * - 契约 §1 行 6a 的响应是 `{data:<消息DTO>}`——`data` **直接就是**那条消息,**没有** `message` + * 包一层。这里两条都试:裸 DTO 必须解出来,包一层的旧形状必须解不出来(免得将来有人 + * 「顺手兼容」把契约放回旧形状); + * - 契约 §1 行 6b 的响应是 `{data:{deleted:}}`; + * - 空 / 纯空白 / 超 4000 rune 的内容**一个请求都不发**(服务端必回 400,白等一个来回); + * - `404 not_found` 必须带得出原因(上层要如实说「这条已经被别的端删了」)。 + */ +class MessageEditRepositoryTest { + + private val backend = FakeMessagesBackend() + + @After + fun tearDown() { + backend.close() + } + + private fun repository() = MessageRepository( + ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java), + ) + + @Test + fun `edit sends PATCH to the message endpoint with only content in the body`() = runBlocking { + backend.reply(200, """{"data":${message(id = "m1", content = "改过的内容")}}""") + + val updated = repository().editMessage("m1", "改过的内容").getOrThrow() + + assertEquals("PATCH", backend.methods.single()) + assertEquals("/api/v1/messages/m1", backend.uris.single()) + // 只发 content 一个键:多带 role / 时间戳只会让后来的人以为客户端能改这些。 + assertEquals("""{"content":"改过的内容"}""", backend.bodies.single()) + assertEquals("m1", updated.id) + assertEquals("改过的内容", updated.content) + } + + @Test + fun `the edit response is the bare message dto, without any message wrapper`() = runBlocking { + // 旧文(v0.30 变更记录)写的是 {data:{message}} —— 以契约正文为准,那是**错的**形状。 + backend.reply(200, """{"data":{"message":${message(id = "m1", content = "x")}}}""") + + val result = repository().editMessage("m1", "x") + + assertTrue(result.isFailure) + } + + @Test + fun `delete sends DELETE to the message endpoint and returns the deleted id`() = runBlocking { + backend.reply(200, """{"data":{"deleted":"m1"}}""") + + val deleted = repository().deleteMessage("m1").getOrThrow() + + assertEquals("DELETE", backend.methods.single()) + assertEquals("/api/v1/messages/m1", backend.uris.single()) + assertEquals("", backend.bodies.single()) + assertEquals("m1", deleted) + } + + @Test + fun `blank content never hits the network`() = runBlocking { + val result = repository().editMessage("m1", " ") + + assertTrue(backend.uris.isEmpty()) + val error = (result.exceptionOrNull() as CyreneException).error as CyreneError.Server + assertEquals(400, error.httpStatus) + assertEquals("消息内容不能为空", error.message) + } + + @Test + fun `content over the rune limit never hits the network either`() = runBlocking { + val result = repository().editMessage("m1", "a".repeat(MESSAGE_EDIT_MAX_RUNES + 1)) + + assertTrue(backend.uris.isEmpty()) + val error = (result.exceptionOrNull() as CyreneException).error as CyreneError.Server + assertEquals(400, error.httpStatus) + // 文案里要带真实数字,用户才知道该删多少。 + assertTrue(error.message, error.message.contains("${MESSAGE_EDIT_MAX_RUNES}")) + assertTrue(error.message, error.message.contains("${MESSAGE_EDIT_MAX_RUNES + 1}")) + } + + @Test + fun `a missing message surfaces as a 404 the caller can explain`() = runBlocking { + backend.reply(404, """{"error":{"code":"not_found","message":"message not found"}}""") + + val error = (repository().editMessage("m1", "x").exceptionOrNull() as CyreneException) + .error as CyreneError.Server + + assertEquals(404, error.httpStatus) + assertEquals("not_found", error.code) + } + + @Test + fun `a failed delete surfaces as a failure instead of a silent success`() = runBlocking { + backend.reply(501, """{"error":{"code":"not_implemented","message":"history store unavailable"}}""") + + val error = (repository().deleteMessage("m1").exceptionOrNull() as CyreneException) + .error as CyreneError.Server + + assertEquals(501, error.httpStatus) + assertFalse(error.userMessage.isBlank()) + } + + private fun message(id: String, content: String): String = + """{"id":"$id","sessionId":"web","role":"user","content":"$content","createdAt":1753000000000}""" + + /** 只回一份固定响应,但把每次请求的方法 / 原始 URI / 请求体都记下来的假后端。 */ + private inner class FakeMessagesBackend : AutoCloseable { + + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + executor = Executors.newFixedThreadPool(2) + createContext("/", ::handle) + start() + } + + val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/" + + val methods = CopyOnWriteArrayList() + val uris = CopyOnWriteArrayList() + val bodies = CopyOnWriteArrayList() + + @Volatile + private var status: Int = 200 + + @Volatile + private var body: String = """{"data":{}}""" + + fun reply(status: Int, body: String) { + this.status = status + this.body = body + } + + private fun handle(exchange: HttpExchange) { + methods += exchange.requestMethod + uris += exchange.requestURI.toString() + // 请求体必须先读完(否则响应发不出去),删 / 改的体都很小。 + bodies += exchange.requestBody.use { it.readBytes().toString(Charsets.UTF_8) } + val bytes = body.toByteArray() + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + override fun close() { + server.stop(0) + } + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageEditsTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageEditsTest.kt new file mode 100644 index 0000000..4ff6f3d --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageEditsTest.kt @@ -0,0 +1,230 @@ +package top.yeij.cyrene.data.repository + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.data.ws.WsEvent + +/** + * D3 的**纯逻辑**:编辑内容的准入、按 `id` 原位改 / 移除,以及「双到达只收敛一次」。 + * + * 这一层是编辑/删除的**唯一落地实现**(VM 的两条路都调它们),所以「广播 + REST 响应都到」 + * 这件事不需要去重表、也不该在 VM 里再写第二套——它由这里的幂等性直接保证: + * 同一个内容做两次与做一次**返回同一个实例**,UI 不会看到两次变更,Compose 的 key 也稳。 + */ +class MessageEditsTest { + + private fun message( + id: String, + role: String = "user", + content: String = id, + createdAt: Long = 100L, + thinking: String? = null, + image: String? = null, + ) = HistoryMessageDto( + id = id, + sessionId = "web", + role = role, + content = content, + thinking = thinking, + image = image, + createdAt = createdAt, + ) + + // —— 准入:≤4000 rune、空白不提交 —— + + @Test + fun `a normal edit is accepted verbatim, without trimming`() { + val raw = " 改过的内容 " + + val validation = validateMessageEdit(raw) + + // 服务端存的就是用户输入的那个字符串;客户端替它 trim 等于悄悄改掉用户的消息 + // (对代码块与 Markdown 引用尤其明显)。 + assertEquals(MessageEditDraft.Accepted(raw), validation) + } + + @Test + fun `blank and whitespace only content is never submitted`() { + assertEquals(MessageEditDraft.Empty, validateMessageEdit("")) + assertEquals(MessageEditDraft.Empty, validateMessageEdit(" ")) + assertEquals(MessageEditDraft.Empty, validateMessageEdit("\n\t ")) + } + + @Test + fun `exactly four thousand runes is allowed and one more is not`() { + val max = "a".repeat(MESSAGE_EDIT_MAX_RUNES) + val over = "a".repeat(MESSAGE_EDIT_MAX_RUNES + 1) + + assertTrue(validateMessageEdit(max) is MessageEditDraft.Accepted) + assertEquals( + MessageEditDraft.TooLong(MESSAGE_EDIT_MAX_RUNES + 1), + validateMessageEdit(over), + ) + } + + @Test + fun `the limit counts code points, not utf16 units`() { + // 4000 个 emoji:`length` 是 8000,但服务端数的是 4000 个 rune —— 按 length 卡会**误伤**, + // 让用户明明还能发的消息发不出去。 + val emoji = "😀".repeat(MESSAGE_EDIT_MAX_RUNES) + + assertEquals(MESSAGE_EDIT_MAX_RUNES * 2, emoji.length) + assertEquals(MESSAGE_EDIT_MAX_RUNES, runeCount(emoji)) + assertTrue(validateMessageEdit(emoji) is MessageEditDraft.Accepted) + + val over = "😀".repeat(MESSAGE_EDIT_MAX_RUNES + 1) + assertEquals(MessageEditDraft.TooLong(MESSAGE_EDIT_MAX_RUNES + 1), validateMessageEdit(over)) + } + + // —— 原位改:只改 content,找不到就忽略 —— + + @Test + fun `an edit changes the content in place and keeps every other field`() { + val before = listOf( + message("m1", content = "原内容", thinking = "我在想", image = "http://x/y.png"), + message("m2", role = "assistant", content = "她的回复"), + ) + + val after = applyMessageEdit(before, "m1", "改过的内容") + + assertEquals(listOf("m1", "m2"), after.map { it.id }) + assertEquals("改过的内容", after[0].content) + // role / 时间戳 / 图片 / 思维链都不动:契约明说这个操作只改 content,而实时帧的 + // thinking 恒为空——整条替换会把本端已渲染的思维链擦掉。 + assertEquals("user", after[0].role) + assertEquals(100L, after[0].createdAt) + assertEquals("http://x/y.png", after[0].image) + assertEquals("我在想", after[0].thinking) + assertEquals("她的回复", after[1].content) + } + + @Test + fun `an edit for an unknown id is ignored instead of inserting a row`() { + val before = listOf(message("m1")) + + val after = applyMessageEdit(before, "nope", "别的会话的消息") + + // 契约 §3.8:找不到该 id 即忽略,避免把别的会话的消息注入当前列表。 + assertSame(before, after) + assertEquals(1, after.size) + } + + @Test + fun `a blank content or blank id is ignored instead of blanking a bubble`() { + val before = listOf(message("m1")) + + // 服务端拒空内容,合法编辑的结果不可能为空 ⇒ 空白是坏帧,宁可整帧忽略也不擦掉气泡正文。 + assertSame(before, applyMessageEdit(before, "m1", "")) + assertSame(before, applyMessageEdit(before, "m1", " ")) + assertSame(before, applyMessageEdit(before, "", "新内容")) + assertSame(before, applyMessageEdit(before, " ", "新内容")) + } + + // —— 原位移除 —— + + @Test + fun `a delete removes exactly the matching row`() { + val before = listOf(message("m1"), message("m2", role = "assistant")) + + val after = removeMessageById(before, "m1") + + assertEquals(listOf("m2"), after.map { it.id }) + } + + @Test + fun `a delete for an unknown id or a blank id changes nothing`() { + val before = listOf(message("m1")) + + assertSame(before, removeMessageById(before, "m2")) + // 坏帧(缺 id)解出空串:删掉所有 id 为空的行不是任何人想要的结果。 + assertSame(before, removeMessageById(before, "")) + assertSame(before, removeMessageById(before, " ")) + } + + // —— 双到达只收敛一次 —— + + @Test + fun `the rest response and the broadcast together change the list exactly once`() { + val before = listOf(message("m1", content = "原内容"), message("m2", role = "assistant", content = "她的回复")) + // 客户端自己发起的 PATCH:响应先到,随后服务端广播同一件事。 + val response = HistoryMessageDto( + id = "m1", + sessionId = "web", + role = "user", + content = "改过的内容", + createdAt = 100L, + ) + val broadcast = broadcastEdit("m1", "改过的内容") + + val afterResponse = applyMessageEdit(before, response.id, response.content) + val afterBoth = applyMessageEdit(afterResponse, broadcast.id, broadcast.content) + + assertEquals(listOf("改过的内容", "她的回复"), afterBoth.map { it.content }) + assertEquals(2, afterBoth.size) + // 第二次是**同一个实例**:这就是「收敛成一次」的可观察形式(没有第二套状态、没有重复行)。 + assertSame(afterResponse, afterBoth) + } + + @Test + fun `the broadcast arriving first converges the same way`() { + val before = listOf(message("m1", content = "原内容")) + val broadcast = broadcastEdit("m1", "改过的内容") + + val afterBroadcast = applyMessageEdit(before, broadcast.id, broadcast.content) + val afterBoth = applyMessageEdit(afterBroadcast, "m1", "改过的内容") + + assertEquals(listOf("改过的内容"), afterBoth.map { it.content }) + assertSame(afterBroadcast, afterBoth) + } + + @Test + fun `a delete response and its broadcast remove the row once`() { + val before = listOf(message("m1"), message("m2", role = "assistant")) + + val afterResponse = removeMessageById(before, "m1") + val afterBoth = removeMessageById(afterResponse, "m1") + + assertEquals(listOf("m2"), afterBoth.map { it.id }) + assertSame(afterResponse, afterBoth) + } + + @Test + fun `another device deleting a message we never loaded changes nothing`() { + val before = listOf(message("m1")) + + // 广播是发给每条连接的(hub 没有按会话订阅),本会话列表里没有那个 id 就该原样不动。 + assertSame(before, removeMessageById(before, "m-elsewhere")) + } + + /** 用**真解析**出来的 `message_edited` 帧做收敛测试,而不是手搓一个 DTO——解析层也是本模块的一部分。 */ + private fun broadcastEdit(id: String, content: String): HistoryMessageDto { + val raw = """{"type":"message_edited","sessionId":"web","data":""" + + """{"id":"$id","sessionId":"web","role":"user","content":"$content","createdAt":100}}""" + val event = WsEvent.parse(raw) as WsEvent.MessageEdited + return requireNotNull(event.message) + } + + // —— 产品口径:谁能编辑、谁能删 —— + + @Test + fun `only user messages are editable`() { + assertTrue(canEditMessage(message("m1", role = "user"))) + assertFalse(canEditMessage(message("m2", role = "assistant"))) + // 未知 role 是开放集合里的东西:不认识就不改(fail-closed)。 + assertFalse(canEditMessage(message("m3", role = "system"))) + assertFalse(canEditMessage(message("m4", role = ""))) + } + + @Test + fun `user and assistant messages are both deletable, unknown roles are not`() { + assertTrue(canDeleteMessage(message("m1", role = "user"))) + // 删掉一句错答是常见需求,服务端对这个端点也不分角色。 + assertTrue(canDeleteMessage(message("m2", role = "assistant"))) + assertFalse(canDeleteMessage(message("m3", role = "system"))) + assertFalse(canDeleteMessage(message("m4", role = ""))) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventMessageEditTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventMessageEditTest.kt new file mode 100644 index 0000000..f46b7fd --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventMessageEditTest.kt @@ -0,0 +1,191 @@ +package top.yeij.cyrene.data.ws + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * D3 下行帧 `message_edited` / `message_deleted` 的解码(契约 §3.8)。 + * + * 帧形状取自契约正文,不是手写猜的: + * + * - `message_edited`:`{type:"message_edited", sessionId, data:<编辑后的完整消息DTO>}` + * - `message_deleted`:`{type:"message_deleted", sessionId, data:{id}}` + * + * 两者都复用 `message_persisted` 的同一条**全连接广播**路径,所以四条要害在这里钉死: + * 1. 会话归属先取帧壳,帧壳没有时 `message_edited` 回落消息自己的 `sessionId`(删除帧没有 + * 第二份可取,只能按全局唯一的 `id` 兜底——那是契约允许的方式,由上层实现); + * 2. 未知字段 / 未知键要安全(`ignoreUnknownKeys`,契约仍在演进); + * 3. 坏帧(缺 `data`、非 JSON、未知 `type`)一律 `null` 或空值,**永不抛**; + * 4. 这两帧同属「仅 owner + admin」那档(§3 角色门),但这层不做角色裁决,只做解析。 + */ +class WsEventMessageEditTest { + + private fun parse(raw: String): WsEvent = + requireNotNull(WsEvent.parse(raw)) { "帧没解出来:$raw" } + + private fun editedFrame( + shellSession: String = """"sessionId":"web",""", + data: String, + ): String = """{"type":"message_edited",$shellSession"data":$data}""" + + @Test + fun `an edited frame carries the whole updated message`() { + val ev = parse( + editedFrame( + data = """{"id":"m1","sessionId":"web","role":"user","content":"改过的内容",""" + + """"mode":"chat","mood":"calm","createdAt":1753000000000}""", + ), + ) as WsEvent.MessageEdited + + assertEquals("message_edited", ev.type) + assertEquals("web", ev.sessionId) + assertEquals("m1", ev.message?.id) + assertEquals("user", ev.message?.role) + assertEquals("改过的内容", ev.message?.content) + assertEquals(1_753_000_000_000L, ev.message?.createdAt) + // 消息类增量帧不走轮次 / 序号(与 message_persisted 一致)。 + assertNull(ev.turnId) + assertNull(ev.seq) + } + + @Test + fun `an edited frame keeps the thinking the wire never carries as null, not as an error`() { + // 契约 v0.59 §4.4:实时帧的 thinking 恒为空(有意剥除),解码层照旧接受缺键。 + val ev = parse(editedFrame(data = """{"id":"m1","sessionId":"web","role":"user","createdAt":1}""")) + as WsEvent.MessageEdited + + assertNull(ev.message?.thinking) + } + + @Test + fun `the message session is used when the shell omits its own`() { + // 帧壳偶尔不带 sessionId:契约要求客户端仍能把它路由到正确的会话桶。 + val ev = parse( + editedFrame( + shellSession = "", + data = """{"id":"m1","sessionId":"web:299bbf0b","role":"user","createdAt":1}""", + ), + ) as WsEvent.MessageEdited + + assertEquals("web:299bbf0b", ev.sessionId) + assertFalse(ev.isForSession("web")) + assertTrue(ev.isForSession("web:299bbf0b")) + } + + @Test + fun `the shell session wins over the message session`() { + val ev = parse( + editedFrame( + shellSession = """"sessionId":"web",""", + data = """{"id":"m1","sessionId":"web:other","role":"user","createdAt":1}""", + ), + ) as WsEvent.MessageEdited + + assertEquals("web", ev.sessionId) + } + + @Test + fun `unknown keys in the payload are ignored`() { + // 契约仍在演进(将来可能给编辑帧加字段),这一层不许把整帧挡回去。 + val ev = parse( + editedFrame( + data = """{"id":"m1","sessionId":"web","role":"user","createdAt":1,""" + + """"clientMsgId":"local-1","futureKey":{"nested":true}}""", + ), + ) as WsEvent.MessageEdited + + assertEquals("m1", ev.message?.id) + } + + @Test + fun `an edited frame without payload keeps a null message instead of inventing one`() { + // 没有 data 就没有 id 也没有 content:上层据此整帧跳过(不编一条空消息出来)。 + val ev = parse("""{"type":"message_edited","sessionId":"web"}""") as WsEvent.MessageEdited + + assertNull(ev.message) + } + + @Test + fun `a deleted frame carries only the id`() { + val ev = parse("""{"type":"message_deleted","sessionId":"web","data":{"id":"m1"}}""") + as WsEvent.MessageDeleted + + assertEquals("message_deleted", ev.type) + assertEquals("m1", ev.id) + assertEquals("web", ev.sessionId) + } + + @Test + fun `a deleted frame without data degrades to a blank id, never to a mass delete`() { + val ev = parse("""{"type":"message_deleted","sessionId":"web"}""") as WsEvent.MessageDeleted + + assertEquals("", ev.id) + } + + @Test + fun `a deleted frame with an explicit null id also degrades to blank`() { + val ev = parse("""{"type":"message_deleted","sessionId":"web","data":{"id":null}}""") + as WsEvent.MessageDeleted + + assertEquals("", ev.id) + } + + @Test + fun `a deleted frame without a shell session is accepted by every session`() { + // 会话缺失 = 「无法判定」⇒ 放行;契约给的兜底是「id 全局唯一,逐桶搜索只会命中它自己那条」。 + val ev = parse("""{"type":"message_deleted","data":{"id":"m1"}}""") as WsEvent.MessageDeleted + + assertNull(ev.sessionId) + assertTrue(ev.isForSession("web")) + assertTrue(ev.isForSession("web:other")) + } + + @Test + fun `a deleted frame for another session is dropped by the session gate`() { + val ev = parse("""{"type":"message_deleted","sessionId":"web:other","data":{"id":"m1"}}""") + + assertFalse(ev.isForSession("web")) + } + + @Test + fun `an edited frame for another session is dropped by the session gate and blank ones pass`() { + val other = parse( + editedFrame( + shellSession = """"sessionId":"web:other",""", + data = """{"id":"m1","sessionId":"web:other","role":"user","createdAt":1}""", + ), + ) + val unknown = parse(editedFrame(shellSession = "", data = """{"id":"m1","role":"user","createdAt":1}""")) + + assertFalse(other.isForSession("web")) + assertTrue(unknown.isForSession("web")) + } + + @Test + fun `a blank or missing payload never throws`() { + // `data:null` / 缺 data:删除帧解成空 id(上层据此整帧跳过),不抛。 + val explicitNull = parse("""{"type":"message_deleted","sessionId":"web","data":null}""") + as WsEvent.MessageDeleted + assertEquals("", explicitNull.id) + + // `data` 根本不是对象(坏帧):删除帧按未知帧忽略(返回 null),编辑帧仍成形但载荷为 null; + // 两条都不会抛,消费方按「没有可用东西」跳过。 + assertNull(WsEvent.parse("""{"type":"message_deleted","sessionId":"web","data":"x"}""")) + val garbage = parse("""{"type":"message_edited","sessionId":"web","data":"x"}""") as WsEvent.MessageEdited + assertNull(garbage.message) + // 编辑帧缺 data:事件仍成形(带帧壳的会话),载荷为 null。 + val noPayload = parse("""{"type":"message_edited","sessionId":"web"}""") as WsEvent.MessageEdited + assertNull(noPayload.message) + assertEquals("web", noPayload.sessionId) + } + + @Test + fun `an unknown frame type and a non json payload are ignored`() { + assertNull(WsEvent.parse("""{"type":"message_removed","data":{"id":"m1"}}""")) + assertNull(WsEvent.parse("not json at all")) + assertNull(WsEvent.parse("""{"data":{"id":"m1"}}""")) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageActionFailureTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageActionFailureTest.kt new file mode 100644 index 0000000..54825d8 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageActionFailureTest.kt @@ -0,0 +1,181 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.CyreneException + +/** + * 编辑 / 删除失败的**如实告知**(纯函数),以及失败该落在哪里。 + * + * 两件事各自钉一组: + * + * 1. [messageActionFailure]:401 / 403 / 404 / 501 / 网络 / 未知,每一档**要用户做的事不同**, + * 文案就不能混。尤其 401——`CyreneError.Unauthorized` 自带的兜底文案是「用户名或密码不正确」, + * 直接透出去会让人去改密码,而正确的动作是重新登录; + * 2. [ChatUiState.applyMessageActionFailure]:弹层还开着就留在弹层里(输入不丢、原地重试), + * 弹层已经关了(用户在等结果时退出)就挂到页面横幅上——**任何一条都不得静默**。 + */ +class MessageActionFailureTest { + + private fun server(code: String, message: String = "", status: Int) = + CyreneException(CyreneError.Server(code, message, status)) + + private fun state(action: MessageActionState?) = ChatUiState(messageAction = action) + + // —— 文案 —— + + @Test + fun `an expired session asks the user to log in again, not to change the password`() { + val expired = CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")) + + val edit = messageActionFailure(expired, MessageActionKind.Edit) + val delete = messageActionFailure(expired, MessageActionKind.Delete) + + assertEquals("登录状态已失效,请重新登录后再编辑", edit) + assertEquals("登录状态已失效,请重新登录后再删除", delete) + assertTrue(edit, !edit.contains("密码")) + } + + @Test + fun `a forbidden edit names the missing permission`() { + val forbidden = server("forbidden", "forbidden", status = 403) + + assertEquals("没有权限编辑这条消息", messageActionFailure(forbidden, MessageActionKind.Edit)) + assertEquals("没有权限删除这条消息", messageActionFailure(forbidden, MessageActionKind.Delete)) + } + + @Test + fun `a message the server no longer has says so, because another client probably removed it`() { + val gone = server("not_found", "message not found", status = 404) + // 契约 §1 行 6a/6b:不存在回 404 not_found。状态码与错误码两条路都要认(代理可能改状态码)。 + val goneByCode = server("not_found", "", status = 400) + + assertEquals( + "这条消息在服务端已不存在,可能已被其它端删除", + messageActionFailure(gone, MessageActionKind.Edit), + ) + assertEquals( + "这条消息在服务端已不存在,可能已被其它端删除", + messageActionFailure(goneByCode, MessageActionKind.Delete), + ) + } + + @Test + fun `a server without a history store says the deployment cannot do it`() { + val notImplemented = server("not_implemented", "history store unavailable", status = 501) + + assertEquals( + "服务端没有开启消息存储,无法编辑", + messageActionFailure(notImplemented, MessageActionKind.Edit), + ) + } + + @Test + fun `a rejection keeps the server wording instead of inventing a number`() { + val rejected = server("bad_request", "content must be at most 4000 runes", status = 400) + + assertEquals("content must be at most 4000 runes", messageActionFailure(rejected, MessageActionKind.Edit)) + } + + @Test + fun `network failures name the step that failed and keep their own wording`() { + val offline = CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)) + val timeout = CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)) + + assertEquals("编辑失败,网络不可用,请检查连接", messageActionFailure(offline, MessageActionKind.Edit)) + assertEquals("删除失败,连接超时,服务器没有响应", messageActionFailure(timeout, MessageActionKind.Delete)) + } + + @Test + fun `an unknown throwable still produces an actionable sentence`() { + assertEquals("编辑失败,请重试", messageActionFailure(IllegalStateException("boom"), MessageActionKind.Edit)) + assertEquals("删除失败,请重试", messageActionFailure(RuntimeException(), MessageActionKind.Delete)) + } + + @Test + fun `no failure path ever yields an empty message`() { + val failures = listOf( + messageActionFailure(server("forbidden", "", status = 403), MessageActionKind.Edit), + messageActionFailure(server("not_found", "", status = 404), MessageActionKind.Edit), + messageActionFailure(server("not_implemented", "", status = 501), MessageActionKind.Edit), + messageActionFailure(server("internal", "", status = 500), MessageActionKind.Delete), + messageActionFailure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Unknown)), MessageActionKind.Delete), + messageActionFailure(IllegalStateException(), MessageActionKind.Edit), + ) + + assertTrue(failures.toString(), failures.all { it.isNotBlank() }) + } + + // —— 失败落在哪里 —— + + @Test + fun `a failure lands inside the dialog while it is still open`() { + val action = MessageActionState.Edit("m1", draft = "改过的内容", inFlight = true) + + val after = state(action).applyMessageActionFailure( + MessageActionKind.Edit, + "m1", + "登录状态已失效,请重新登录后再编辑", + ) + + // 留在弹层里:用户就站在那儿,改完能直接重试,草稿一个字都不丢。 + assertEquals("登录状态已失效,请重新登录后再编辑", (after.messageAction as MessageActionState.Edit).error) + assertEquals("改过的内容", (after.messageAction as MessageActionState.Edit).draft) + assertEquals(false, after.messageAction?.inFlight) + assertNull(after.error) + } + + @Test + fun `a failed delete keeps its own dialog too`() { + val action = MessageActionState.Delete("m1", inFlight = true) + + val after = state(action).applyMessageActionFailure( + MessageActionKind.Delete, + "m1", + "这条消息在服务端已不存在,可能已被其它端删除", + ) + + assertEquals( + "这条消息在服务端已不存在,可能已被其它端删除", + (after.messageAction as MessageActionState.Delete).error, + ) + } + + @Test + fun `a failure for another message or another kind goes to the page banner`() { + // 用户在等结果时又去操作了别的消息(或关掉了弹层)⇒ 失败挂到页面顶部那行,**绝不静默**。 + val otherMessage = state(MessageActionState.Edit("m2", draft = "别的")) + val otherKind = state(MessageActionState.Delete("m1")) + + val afterOtherMessage = otherMessage.applyMessageActionFailure(MessageActionKind.Edit, "m1", "改失败了") + val afterOtherKind = otherKind.applyMessageActionFailure(MessageActionKind.Edit, "m1", "改失败了") + + assertEquals("改失败了", afterOtherMessage.error) + assertEquals("别的", (afterOtherMessage.messageAction as MessageActionState.Edit).draft) + assertEquals("改失败了", afterOtherKind.error) + assertEquals("m1", afterOtherKind.messageAction?.messageId) + } + + @Test + fun `a failure with no dialog open still shows up on the page`() { + val after = ChatUiState().applyMessageActionFailure(MessageActionKind.Delete, "m1", "删不掉") + + assertEquals("删不掉", after.error) + assertNull(after.messageAction) + } + + @Test + fun `an unrelated failure leaves the state untouched`() { + // 幂等的一面:失败的 messageId 与弹层无关且已有关闭前的横幅时,不做多余的改写。 + val before = state(MessageActionState.Edit("m1", draft = "x", inFlight = true)) + + val after = before.applyMessageActionFailure(MessageActionKind.Edit, "m2", "别的消息失败了") + + assertSame(before.messageAction, after.messageAction) + assertEquals("别的消息失败了", after.error) + } +}