merge: W19 — 单条消息的编辑与删除(D3)
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user