merge: W19 — 单条消息的编辑与删除(D3)

This commit is contained in:
AskaEth committed 2026-10-04 21:28:22 +08:00
commit ea16f48be2
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())
@@ -437,6 +437,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(
@@ -548,6 +553,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,
)
}
}
}
/**
@@ -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`**(含 `<p>` / `<action>` 这类标记)——
* 编辑的就是那个字符串本身,见 [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<ChatDisplayItem>
@@ -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`**(可能带 `<p>` / `<action>` 这类标记)——那是唯一真源,
* 编辑它才是「改这条消息」;先剥标记再回写会静默丢掉旁白/动作。
*/
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}失败,请重试"
}
}
@@ -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)时按 `<p>` 切分,
@@ -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<ContentPart>) {
}
@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),
) {
@@ -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 渲染链路的标记(`<p>` / `<action>` /
* `<narration>`),它们在气泡里被 `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
@@ -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:<id>}}`;
* - 空 / 纯空白 / 超 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<String>()
val uris = CopyOnWriteArrayList<String>()
val bodies = CopyOnWriteArrayList<String>()
@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)
}
}
}
@@ -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 = "")))
}
}
@@ -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"}}"""))
}
}
@@ -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)
}
}