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:
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
|
||||
Reference in new issue
Block a user