feat(chat): 单条消息的编辑与删除(D3)

契约 §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。
This commit is contained in:
AskaEth committed 2026-10-04 21:26:52 +08:00
1 parent 020687a82b
commit 351891714a
13 files changed
+1717 -15

No files matched your search

@@ -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<ApiEnvelope<ReadSessionResponse>>
/**
* 编辑单条消息(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<ApiEnvelope<HistoryMessageDto>>
/**
* 删除单条消息(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<ApiEnvelope<DeleteMessageResponse>>
}
@@ -44,3 +44,29 @@ data class HistoryMessageDto(
val documents: List<DocumentRefDto> = 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,
)
@@ -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<HistoryMessageDto>,
id: String,
content: String,
): List<HistoryMessageDto> {
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<HistoryMessageDto>, id: String): List<HistoryMessageDto> {
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"
@@ -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<HistoryMessageDto> {
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<String> = withContext(dispatcher) {
apiCall { api.deleteMessage(messageId) }.map { it.deleted }
}
companion object {
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 200
@@ -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<PersistedData>(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<HistoryMessageDto>(data).let {
MessageEdited(it, turnId, seq, sessionId ?: it?.sessionId?.takeIf(String::isNotBlank))
}
// 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。
"message_deleted" -> decode<DeletedData>(data)
.let { MessageDeleted(it.id, turnId, seq, sessionId) }
"voice_output" -> decode<VoiceOutputData>(data)
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
"proactive_message" -> decode<ProactiveData>(data).let {
@@ -646,6 +701,16 @@ sealed class WsEvent {
private inline fun <reified T> decode(data: JsonElement?): T =
ApiClientFactory.json.decodeFromJsonElement<T>(data ?: JsonObject(emptyMap()))
/**
* 与 [decode] 同源,但**解不出来就回 `null`**。
*
* 用在「帧本身还有意义、载荷却可能是坏形状」的地方:[MessageEdited] 的载荷缺失时,
* 事件仍带着帧壳上的 `sessionId` 成形(上层据此整帧跳过),而不是把整帧按坏帧丢掉——
* 丢掉会让「这一帧来过」这件事完全不可见。
*/
private inline fun <reified T> decodeOrNull(data: JsonElement?): T? =
runCatching { decode<T>(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<AdminTraceSpanDto> = emptyList())