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

契约 §1 行 6a/6b 的两个端点与 §3.8 的两帧,整条链接完(不含「重发」——契约
明确没有重发端点,客户端用编辑后的内容再 POST /chat 即可,本模块不做)。

数据层
- SessionsApi 增 PATCH / DELETE api/v1/messages/{id}(与既有消息端点同源,因此
  MessageRepository 能直接复用、不必动 AppContainer),MessageRepository 增
  editMessage / deleteMessage,走既有 apiCall / Result 风格。
- 响应形状照契约不照旧文:PATCH 的 data 直接就是消息 DTO(没有 message 包一层),
  DELETE 回 {deleted: id};编辑内容按「非空、≤4000 rune(码点,不是 length)」
  预校验,不合格一个请求都不发。

帧解析
- WsEvent 增 message_edited / message_deleted,形态照 message_persisted 那一套:
  会话归属先取帧壳、其次取消息自身的 sessionId,未知键安全、未知帧与坏帧安静忽略、
  永不抛。
- message_edited 的 data 就是消息本身(与 message_persisted 的 data.message 不同形),
  照抄那一套会解出 null——这一点由单测钉住。

VM
- 按 id 原位改 content / 原位移除。两条路各自只有一个落地出口:本端 PATCH/DELETE 的
  成功回执与随后到达的广播都调同一对纯函数,而它们幂等(同一内容做两次返回同一实例)
  ⇒ 「响应 + 广播」双到达收敛成一次,不需要去重表、也不会出现两套状态源。
- 编辑只对 role == user 开放、删除对 user 与 assistant 都开放,判据是同一份纯函数,
  气泡(隐藏入口)与 VM(再挡一次)共用。
- 失败一律如实:弹层还开着就留在弹层里(输入不丢、可原地重试),弹层已被关掉就挂到
  页面顶部那行横幅,绝不静默。

UI
- 长按气泡打开动作菜单;由于正文包在 SelectionContainer 里、长按正文同时也是选字手势
  (两者抢同一个长按,谁先消费由运行时决定),另给一枚看得见的「⋯」
  (content-desc 消息操作)作为同一条入口,避免只有知道的人才会用。
- 编辑弹层:原始 content 作初值 + 码点计数 + 取消/保存;删除必须二次确认。
- ChatScreen 只加 24 行接线(两个回调 + 两个弹层的渲染)。

测试 +49(766 → 815,全绿):帧解析 14、纯逻辑 15、Retrofit 层 7、失败文案与落点 13。
This commit is contained in:
AskaEth committed 2026-10-04 21:26:52 +08:00
1 parent 020687a82b
commit 351891714a
13 files changed
+1717 -15

No files matched your search

@@ -2,11 +2,13 @@ package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.CreateSessionRequest
import top.yeij.cyrene.data.api.dto.CreateSessionResponse
import top.yeij.cyrene.data.api.dto.DeleteMessageResponse
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.api.dto.UpdateMessageRequest
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
import retrofit2.Response
@@ -102,4 +104,36 @@ interface SessionsApi {
@Path("id") sessionId: String,
@Body body: ReadSessionRequest,
): Response<ApiEnvelope<ReadSessionResponse>>
/**
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,body `{content}`。
*
* 响应是 `{data:<消息DTO>}`——`data` **直接就是**更新后的完整消息 DTO,**没有** `message`
* 包一层(v0.30 变更记录里那句 `{data:{message}}` 已作废,以契约正文为准)。服务端只改
* `content`,其余字段一律原值返回。
*
* 失败面:`content` 空 / 纯空白 / 超 4000 rune → `400 bad_request`;消息不存在 →
* `404 not_found`;未注入 HistoryStore → `501`;权限不足 → `403`。
*
* ⚠️ 成功后服务端**还会广播** `message_edited`(§3.8)——发起的这条连接自己也会收到它。
* 于是「REST 响应」与「广播帧」会把同一件事说两遍,两处必须收敛成一次状态变更
* (见 [top.yeij.cyrene.data.repository.applyMessageEdit])。
*/
@PATCH("api/v1/messages/{id}")
suspend fun updateMessage(
@Path("id") messageId: String,
@Body body: UpdateMessageRequest,
): Response<ApiEnvelope<HistoryMessageDto>>
/**
* 删除单条消息(D3,契约 §1 行 6b):`DELETE /api/v1/messages/{id}` →
* `{data:{deleted:<消息 id>}}`。
*
* 失败面:消息不存在 → `404 not_found`;未注入 HistoryStore → `501`;权限不足 → `403`。
* 成功后同样会广播 `message_deleted`(§3.8,只带被删消息的 `id`)。
*/
@DELETE("api/v1/messages/{id}")
suspend fun deleteMessage(
@Path("id") messageId: String,
): Response<ApiEnvelope<DeleteMessageResponse>>
}
@@ -44,3 +44,29 @@ data class HistoryMessageDto(
val documents: List<DocumentRefDto> = emptyList(),
val createdAt: Long,
)
/**
* `PATCH /api/v1/messages/{id}` 的请求体(D3,契约 §1 行 6a)。
*
* **只有 `content` 一个键**:服务端按它改 `content`,role / 时间戳 / 平台 / 图片 / 附件一律保持原值,
* 所以这里**不能**多带任何字段(多带也不会被采纳,只会让后来的读者以为客户端能改这些)。
*
* 服务端口径:非空、≤ **4000 rune**;空 / 纯空白 / 超长回 `400 bad_request`。
* [content] 要发**用户原样输入的内容**(含首尾空白)——服务端存的就是这个字符串,
* 客户端替它 trim 一次等于悄悄改掉用户的消息。
*/
@Serializable
data class UpdateMessageRequest(
val content: String,
)
/**
* `DELETE /api/v1/messages/{id}` → `200 {"data":{"deleted":"<消息 id>"}}`(契约 §1 行 6b)。
*
* 与 [DeleteSessionResponse] 逐字同形,但**刻意分开**:这两个 id 的命名空间和「删掉了什么」
* 是两件事,共用一个类型会让调用方以为删的是会话。
*/
@Serializable
data class DeleteMessageResponse(
val deleted: String,
)
@@ -0,0 +1,123 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
/**
* 编辑内容的上限(契约 §1 行 6a:非空、≤ **4000 rune**)。
*
* 「rune」是**码点**而不是 `String.length`:后者数的是 UTF-16 码元,emoji 与绝大多数中文扩展字符
* 会被算成两个。按 `length` 卡上限会在边界上**误伤**(客户端以为超了、不发;服务端其实收得下),
* 也会**误放**(客户端以为没超、发出去被 400)。
*/
const val MESSAGE_EDIT_MAX_RUNES = 4000
/**
* 一次编辑提交的准入判定(纯函数的结果,见 [validateMessageEdit])。
*
* 分成三档是因为**用户要做的动作不同**:空了要接着打字,超长了要删字,只有 [Accepted] 才该发请求。
*/
sealed interface MessageEditDraft {
/** 可以提交;[content] 就是要发给服务端的字符串(原样,**不 trim**——见 [validateMessageEdit])。 */
data class Accepted(val content: String) : MessageEditDraft
/** 空 / 纯空白:服务端会回 `400`,本端**不发这个请求**。 */
data object Empty : MessageEditDraft
/** 超过 [max] 个码点:服务端会回 `400`,本端同样不发。 */
data class TooLong(val runes: Int, val max: Int = MESSAGE_EDIT_MAX_RUNES) : MessageEditDraft
}
/**
* 校验一次编辑的内容(纯函数)。
*
* **不 trim**:服务端存的就是用户输入的那个字符串(契约只承诺「非空」),客户端替它去掉首尾空白
* 等于悄悄改掉用户的消息——对代码块、Markdown 引用这类内容尤其明显。空白只用来判「空」。
*
* 计数按**码点**([runeCount]),与服务端 Go 的 `utf8.RuneCountInString` 同口径。
*/
fun validateMessageEdit(raw: String): MessageEditDraft {
if (raw.isBlank()) return MessageEditDraft.Empty
val runes = runeCount(raw)
return if (runes > MESSAGE_EDIT_MAX_RUNES) {
MessageEditDraft.TooLong(runes)
} else {
MessageEditDraft.Accepted(raw)
}
}
/** 按 Unicode 码点计数:代理对(emoji、部分汉字)算一个,与服务端 rune 口径一致。 */
fun runeCount(text: String): Int = text.codePointCount(0, text.length)
/**
* 按 `id` **原位**改一条消息的 `content`(纯函数;编辑落地的唯一实现)。
*
* 三条刻意的取舍:
* 1. **只改 `content`,不整条替换**。`message_edited` 帧里的 DTO 是完整消息,但它的 `thinking`
* 恒为空(契约 v0.59 §4.4:实时帧一律剥离思维链)。整条替换会把本端已渲染的思维链擦掉,
* 而契约明说这个操作**只改 content**——照它做,就不会有第二种解读。
* 2. **找不到这个 `id` 就原样返回**,绝不插入。契约 §3.8 写得很直白:「找不到该 id 即忽略,
* 避免把别的会话的消息注入当前列表」。别人的会话里那条消息不该在这里冒出来。
* 3. **空内容是坏帧,不落**。服务端拒空内容(400),合法的编辑结果不可能为空;真收到空白就把
* 气泡擦成空白,是拿坏帧毁掉一条好消息。宁可整帧忽略。
*
* **幂等**:[content] 与现值相同时返回**同一个实例**(Compose 的 key 稳定性靠它),因此
* 「REST 响应先到、广播后到」(或反过来)连做两次与做一次完全等价——这就是双到达收敛的根据。
* 不修改入参。
*/
fun applyMessageEdit(
messages: List<HistoryMessageDto>,
id: String,
content: String,
): List<HistoryMessageDto> {
if (id.isBlank() || content.isBlank()) return messages
var changed = false
val updated = messages.map { message ->
if (message.id != id || message.content == content) {
message
} else {
changed = true
message.copy(content = content)
}
}
return if (changed) updated else messages
}
/**
* 按 `id` 移除一条消息(纯函数;删除落地的唯一实现)。
*
* `id` 为空白即原样返回:`message_deleted` 的载荷是 `{id}`,坏帧(缺键)解出空串,
* 而「删掉所有 id 为空的消息」不是任何人想要的结果。找不到该 `id` 同样原样返回(幂等)——
* 「我们自己的 DELETE 回执 + 广播」两次到达、以及别的端删掉一条本端还没加载的消息,都走这里。
*
* 删除的**路由**由帧壳的 `sessionId` 负责(见
* [top.yeij.cyrene.data.ws.isForSession]);`sessionId` 缺席时契约允许按**全局唯一**的 `id`
* 兜底——那正是这个函数天然的行为:别的会话的列表里根本不存在这个 `id`。不修改入参。
*/
fun removeMessageById(messages: List<HistoryMessageDto>, id: String): List<HistoryMessageDto> {
if (id.isBlank()) return messages
// 没有这一条就原样返回**同一个实例**(幂等的可观察形式:连做两次与做一次完全等价,
// 也不会白分配一个新列表)。
if (messages.none { it.id == id }) return messages
return messages.filterNot { it.id == id }
}
/**
* 这条消息**能不能编辑**(产品口径,UI 与 VM 共用同一份判据)。
*
* 只放 `user`:assistant 的回复是她说过的话的历史记录,改它等于伪造她说过什么;而要「重发」
* 一条说错的回复,正确的动作是删掉它再问一遍(契约也明确没有重发端点)。
*
* 用 `== "user"` 而不是「非 assistant」:`role` 在后端是**开放集合**,将来多出一个角色时
* fail-closed(不改)比「默认都能改」安全。
*/
fun canEditMessage(message: HistoryMessageDto): Boolean = message.role == "user"
/**
* 这条消息**能不能删除**(产品口径,UI 与 VM 共用同一份判据)。
*
* user 与 assistant 都放:删掉一句错答是常见需求,而且服务端对这个端点本就不分角色。
* 未知 `role` 一律不放(fail-closed)——不认识的东西不主动去删。
*/
fun canDeleteMessage(message: HistoryMessageDto): Boolean =
message.role == "user" || message.role == "assistant"
@@ -1,15 +1,18 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.apiCall
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.api.dto.UpdateMessageRequest
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 会话消息分页获取层。服务端按倒序返回,这里统一转成升序的 [PagedMessages]。
* 去重 / 合并请用 [mergeMessages]。
* 会话消息层:分页 / 搜索**读取**,以及单条消息的**编辑 / 删除**(D3)。
* 服务端按倒序返回分页结果,这里统一转成升序的 [PagedMessages]。去重 / 合并请用 [mergeMessages]。
*/
class MessageRepository(
private val api: SessionsApi,
@@ -66,6 +69,51 @@ class MessageRepository(
.map { raw -> toPagedMessages(raw, pageSize) }
}
/**
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的
* 完整消息 DTO**。
*
* 校验在**发请求之前**做([validateMessageEdit]):空 / 纯空白 / 超 4000 rune 的内容服务端
* 一定回 `400`,明知会被拒还发一次只是白等一个来回。被拒时返回的失败**带得出原因**——
* 调用方要把「为什么没提交」如实说出来,不能静默什么都不发生。
*
* 返回的是服务端那一份而不是本地改过的副本:调用方据此更新界面([applyMessageEdit]),
* 与随后到达的 `message_edited` 广播收敛成同一次变更。
*/
suspend fun editMessage(messageId: String, content: String): Result<HistoryMessageDto> {
val draft = when (val validation = validateMessageEdit(content)) {
MessageEditDraft.Empty -> return Result.failure(
CyreneException(CyreneError.Server("bad_request", "消息内容不能为空", 400)),
)
is MessageEditDraft.TooLong -> return Result.failure(
CyreneException(
CyreneError.Server(
"bad_request",
"消息内容不能超过 ${validation.max} 个字符(当前 ${validation.runes})",
400,
),
),
)
is MessageEditDraft.Accepted -> validation.content
}
return withContext(dispatcher) {
apiCall { api.updateMessage(messageId, UpdateMessageRequest(content = draft)) }
}
}
/**
* 删除单条消息(D3,契约 §1 行 6b):`DELETE /api/v1/messages/{id}`,成功返回被删消息的 `id`。
*
* 没有本地校验可做(只有一个路径参数),所以失败一律来自服务端:`404 not_found`(这条已经
* 不在服务端了,常见于别的端先删掉)、`403`(权限)、`501`(没接 HistoryStore)、网络。
* 调用方要把这些如实说出来——**静默失败会让人以为删掉了,而她下一条回复里那条还在**。
*/
suspend fun deleteMessage(messageId: String): Result<String> = withContext(dispatcher) {
apiCall { api.deleteMessage(messageId) }.map { it.deleted }
}
companion object {
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 200
@@ -367,6 +367,52 @@ sealed class WsEvent {
override val type: String get() = "channel_persisted"
}
/**
* 一条消息被编辑(D3,契约 §3.8,api-client 上行端点见 §1 行 6a)。
*
* `data` 是**编辑后的完整消息 DTO**(与 [MessagePersisted] 同形,含 `id` / `content` /
* `sessionId` / `createdAt`,另带 `role` / `mode?` / `mood?` / `image?` / `platform?` /
* `documents`),`thinking` 已按契约剥离。
*
* 三处要害:
* 1. 它是**全连接广播**(hub 没有按会话订阅),所以会话归属仍要走 [isForSession];
* 帧壳通常带 `sessionId`,缺席时用 `data` 里消息自己的 `sessionId` 兜底;
* 2. 消费方**按 `id` 原位改 `content`**,找不到忽略(绝不插入——契约 §3.8);
* 3. **本端自己发起的编辑也会收到这一帧**(`PATCH` 成功后服务端照旧广播),于是它与
* REST 响应是同一件事的两种到达方式,必须收敛成一次(见
* [top.yeij.cyrene.data.repository.applyMessageEdit] 的幂等性)。
*
* [message] 为 `null` = 帧里没有可用载荷;此时**整帧跳过**(没有 id 也没有 content,
* 无事可做),而不是编一条空消息出来。
*/
data class MessageEdited(
val message: HistoryMessageDto?,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "message_edited"
}
/**
* 一条消息被删除(D3,契约 §3.8):`{type:"message_deleted", sessionId, data:{id}}`。
*
* 载荷**只有** `id`:契约刻意不给整条消息(客户端要做的只是把它拿掉)。会话归属来自帧壳
* (后端在删除前读一次该消息所属会话填进去);[sessionId] 缺席时按**全局唯一**的 `id`
* 兜底——逐桶搜索只会命中它自己那条,这正是契约允许的兜底方式。
*
* [id] 空白(坏帧)时上层什么都不做(见 [top.yeij.cyrene.data.repository.removeMessageById]):
* 「删掉所有 id 为空的消息」不是任何人想要的结果。
*/
data class MessageDeleted(
val id: String,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
) : WsEvent() {
override val type: String get() = "message_deleted"
}
/**
* 指标快照(契约 v0.55 §3.12「可观测实时推送」)。
*
@@ -618,6 +664,15 @@ sealed class WsEvent {
.let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
"channel_persisted" -> decode<PersistedData>(data)
.let { ChannelPersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
// 消息编辑(D3 §3.8):`data` **就是**编辑后的完整消息 DTO——**没有** `message`
// 包一层(这与 message_persisted 的 `data:{message:{…}}` 形状**不同**,照抄会解出 null)。
// 坏帧(缺 data / 结构不对)解出 null:事件照旧成形,上层按「没有可用载荷」整帧跳过。
"message_edited" -> decodeOrNull<HistoryMessageDto>(data).let {
MessageEdited(it, turnId, seq, sessionId ?: it?.sessionId?.takeIf(String::isNotBlank))
}
// 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。
"message_deleted" -> decode<DeletedData>(data)
.let { MessageDeleted(it.id, turnId, seq, sessionId) }
"voice_output" -> decode<VoiceOutputData>(data)
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
"proactive_message" -> decode<ProactiveData>(data).let {
@@ -646,6 +701,16 @@ sealed class WsEvent {
private inline fun <reified T> decode(data: JsonElement?): T =
ApiClientFactory.json.decodeFromJsonElement<T>(data ?: JsonObject(emptyMap()))
/**
* 与 [decode] 同源,但**解不出来就回 `null`**。
*
* 用在「帧本身还有意义、载荷却可能是坏形状」的地方:[MessageEdited] 的载荷缺失时,
* 事件仍带着帧壳上的 `sessionId` 成形(上层据此整帧跳过),而不是把整帧按坏帧丢掉——
* 丢掉会让「这一帧来过」这件事完全不可见。
*/
private inline fun <reified T> decodeOrNull(data: JsonElement?): T? =
runCatching { decode<T>(data) }.getOrNull()
}
}
@@ -750,6 +815,13 @@ private data class SessionReadData(val readAt: Long = 0L)
@Serializable
private data class PersistedData(val message: HistoryMessageDto? = null)
/**
* `message_deleted` 载荷(§3.8):`{id}`。缺键 / 空串解成空 `id`,上层据此整帧跳过
* (见 [top.yeij.cyrene.data.repository.removeMessageById]),不会误删。
*/
@Serializable
private data class DeletedData(val id: String = "")
/** `observability_trace` 载荷(后端 `gateway.AdminTracePush`):`{spans:[…]}`,最新在前。 */
@Serializable
private data class TracePushData(val spans: List<AdminTraceSpanDto> = emptyList())
@@ -431,6 +431,11 @@ fun ChatScreen(
onImageClick = { raw ->
ImageSource.of(raw)?.let { imageTarget = it }
},
// 编辑 / 删除(D3):弹层状态活在 VM(`state.messageAction`),
// 这里只把用户的意图转过去。能不能编辑、能不能删由 VM 与气泡
// 各自用同一份产品口径的纯函数判定。
onEdit = viewModel::startEdit,
onDelete = viewModel::startDelete,
)
is ChatDisplayItem.Pending -> PendingBubble(
@@ -539,6 +544,25 @@ fun ChatScreen(
onDismiss = { imageTarget = null },
)
}
// 单条消息的编辑 / 删除弹层(D3)。状态全在 VM 里:这是**唯一**一处接线,
// 失败也由 VM 决定「留在弹层里说」还是「挂到页面横幅上说」(见 applyMessageActionFailure)。
state.messageAction?.let { action ->
when (action) {
is MessageActionState.Edit -> MessageEditDialog(
state = action,
onDraftChange = viewModel::onEditDraftChange,
onSubmit = viewModel::submitEdit,
onDismiss = viewModel::cancelMessageAction,
)
is MessageActionState.Delete -> MessageDeleteDialog(
state = action,
onConfirm = viewModel::confirmDelete,
onDismiss = viewModel::cancelMessageAction,
)
}
}
}
/**
@@ -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