merge: W52DE —— 降级气泡不再给删改入口(proactive-local: 前缀屏蔽服务端动作);空态补一枚显式「重试」

This commit is contained in:
AskaEth committed 2026-10-06 15:50:30 +08:00
commit 27116fa434
6 files changed
+323 -18

No files matched your search

@@ -82,6 +82,7 @@ import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
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.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
@@ -511,7 +512,20 @@ fun ChatScreen(
) {
when {
state.loading && state.items.isEmpty() -> CenterHint("加载中…")
state.items.isEmpty() -> CenterHint("还没有消息")
// 空列表这一档有两种真相,屏幕上必须分得开(W52/E):**真的还没有消息**,
// 与**消息没能加载出来**(首屏 REST 失败)。后者此前也念「还没有消息」,
// 于是唯一的出路只剩「切走再回来」——现在给一枚显式「重试」。
state.items.isEmpty() -> {
val retry = shouldOfferRetryOnEmpty(
loading = state.loading,
hasError = state.error != null,
hasItems = state.items.isEmpty(),
)
CenterHint(
text = if (retry) FIRST_PAGE_FAILED_HINT else EMPTY_CONVERSATION_HINT,
onRetry = if (retry) viewModel::retryFirstPage else null,
)
}
else -> LazyColumn(
state = listState,
reverseLayout = true,
@@ -927,14 +941,66 @@ private fun DraftImagePreviews(images: List<String>, onRemove: (Int) -> Unit) {
}
}
/**
* 空列表这一刻该不该给一个显式「重试」(**纯函数**,W52/E)。
*
* 三件事同时成立才给:**不在加载中**(在飞的时候给重试只会把同一趟请求发两遍)、
* **有一个错误挂着**、**列表是空的**。任一条不成立就不给——正常空会话(服务端真的没消息)
* 与正在加载首屏那两个时刻,屏幕上都不该多出一枚按了没什么用的按钮。
*
* ## 它**不**声称「这就是首屏失败」
* 「首屏有没有成功过」这件事只有 VM 里的 `firstPageState` 说得准,而按 W51 的纪律那个字段
* **只许由 `loadFirstPage()` 的两个出口写**([reconnectPull] 拿它当判据),所以这里刻意不去读它、
* 也不去镜像一份。这里只看**屏幕上成立的三件事**:什么都没显示、而且刚出过错。这三件事一起成立时,
* 用户能做的下一件事就是「再试一次」——按下去走的正是首屏那一份实现(`ChatViewModel.retryFirstPage`)。
*
* ⚠️ 已知的一处不精确:错误横幅上的那个 X 会把 `error` 清掉(`dismissError`),
* 于是按钮**跟着一起消失**。这是判据只看公开状态的代价,本轮按主人拍的口径留着。
*
* @param hasItems 列表里有没有东西(含乐观气泡与流式气泡——它们在也算「有东西」,
* 那时候屏幕不是空的,不该出现这枚按钮)。
*/
internal fun shouldOfferRetryOnEmpty(
loading: Boolean,
hasError: Boolean,
hasItems: Boolean,
): Boolean = !loading && hasError && !hasItems
/** 空列表 + 没出错:服务端就是没有消息,如实说一句(既有文案,一个字没改)。 */
private const val EMPTY_CONVERSATION_HINT = "还没有消息"
/**
* 空列表 + 挂着一个错误:消息没能加载出来(首屏 REST 失败那一档)。
*
* 与 [EMPTY_CONVERSATION_HINT] 分开是**如实**的要求:「还没有消息」说的是这个会话里什么都没有,
* 而这一刻本端其实**不知道**——它只是没拿到。原因由顶部那行错误横幅说,这里只说结论,
* 并配一枚「重试」。
*/
private const val FIRST_PAGE_FAILED_HINT = "消息没能加载出来"
/**
* 屏幕正中那句提示;[onRetry] 非空时在它下面多一枚 Miuix 的 `TextButton`。
*
* 按钮用的就是本文件输入栏「发送」那枚 `Button` 的同一个 Miuix 控件族里的 `TextButton`
* (编辑 / 删除弹层用的也是它):项目里没有 Miuix 的菜单类组件,而「凭空去猜一个没见过的控件」
* 是刻意避开的做法(见 `MessageActionMenu` 的 KDoc)。底部居中放一枚文字按钮,不抢正文区。
*/
@Composable
private fun CenterHint(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
private fun CenterHint(text: String, onRetry: (() -> Unit)? = null) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
if (onRetry != null) {
Spacer(Modifier.height(12.dp))
TextButton(text = "重试", onClick = onRetry)
}
}
}
@@ -32,8 +32,6 @@ import top.yeij.cyrene.data.repository.MessageEditDraft
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.PagedMessages
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
@@ -785,8 +783,10 @@ data class ChatUiState(
* 用的还是同一个键——**同一条消息无论在路上摔了几次,服务端都只认一条**。
*
* ## 单条消息的编辑 / 删除(D3)
* 编辑**只对 `user` 消息开放**([canEditMessage]),删除对 `user` 与 `assistant` 都开放
* ([canDeleteMessage])——判据是同一份纯函数,UI 与这里共用,不存在两套口径。
* 编辑**只对 `user` 消息开放**,删除对 `user` 与 `assistant` 都开放,而且两条都要求**这条消息在
* 服务端存在**([canEditMessageOnServer] / [canDeleteMessageOnServer]:角色口径 + `id` 前缀那道闸,
* W52/D)——判据是同一份纯函数,UI 与这里共用,不存在两套口径。降级气泡因此两个入口都没有:
* 它服务端根本没有那一行,拿本地 id 打过去只会白摔一趟。
*
* 两条路各自**只有一个落地出口**([applyMessageEdited] / [applyMessageDeleted]):本端自己发起的
* `PATCH` / `DELETE` 成功回执,与随后到达的 `message_edited` / `message_deleted` 广播,都调它们;
@@ -1016,6 +1016,24 @@ class ChatViewModel(
}
}
/**
* **用户显式重试首屏**(W52/E:空态上那枚「重试」)。
*
* 它补的是登记在案的那个缺口:**REST 首屏失败了、而 WS 从头到尾没断过**。这种时候没有任何
* 状态变化可挂——连接没变过([onConnectionChanged] 不会再来一次)、也没有断线窗口要补
* ([gapCursor] 是 null);此前唯一的出路是用户自己切走再回来,或者上拉去触发 [loadOlder]。
*
* 修法刻意是**用户动作**而不是定时器:本项目从 W38 起就定死了「状态驱动,不按时间轮」
* (见 [onConnectionChanged] 末尾那段),而且定时重试会反复把「首屏失败」这个事实擦成
* [FirstPageState.Loading],那正是 [reconnectPull] 拿它当判据要避免的。
*
* 实现上**只是**转调 [loadFirstPage](首屏唯一的一份实现,与连接恢复时补的那一次共用):
* `firstPageState` 的两个出口仍然只有那一处,本方法**一个状态位都不自己写**——
* W51 的判据依赖「这个字段只由 [loadFirstPage] 写」这条纪律。[repository] 还没建好时
* [loadFirstPage] 自己会原样返回,这里不必再判一次。
*/
fun retryFirstPage() = loadFirstPage()
/**
* 连接状态变化:除了照抄给界面,还要顺手维护「断线期间漏了哪一段」这个游标,并在连接恢复
* 那一刻决定**这一轮要不要补拉、补哪一段**(判据是纯函数 [reconnectPull])。
@@ -1123,8 +1141,8 @@ class ChatViewModel(
// —— D3:单条消息的编辑 / 删除 ——
/**
* 打开编辑弹层。**只对 `user` 消息开放**(口径见 [canEditMessage],UI 隐藏入口 + 这里再挡一次,
* 判据是同一份纯函数)。
* 打开编辑弹层。**只对服务端存在的 `user` 消息开放**(口径见 [canEditMessageOnServer],
* UI 隐藏入口 + 这里再挡一次,判据是同一份纯函数)。
*
* 初值走 [editableMessageOf]:编辑框里是**纯文本**(剥掉 `<p>` / `<action>` / `<narration>`),
* 原文本来就是纯文本时逐字节不变。原文真带标记的话,那件事**不能不说**——所以
@@ -1133,7 +1151,7 @@ class ChatViewModel(
* 只算一次:`_state.update` 的 lambda 可能被重跑(CAS 重试),别把纯函数调用塞进它里面。
*/
fun startEdit(message: HistoryMessageDto) {
if (!canEditMessage(message)) return
if (!canEditMessageOnServer(message)) return
val editable = editableMessageOf(message.content)
_state.update {
it.copy(
@@ -1146,9 +1164,12 @@ class ChatViewModel(
}
}
/** 打开删除的二次确认弹层(`user` 与 `assistant` 都开放,见 [canDeleteMessage])。 */
/**
* 打开删除的二次确认弹层(服务端存在的 `user` 与 `assistant` 消息都开放,
* 见 [canDeleteMessageOnServer]——**降级气泡不在其中**,服务端没有那一行,删它只会白打一趟)。
*/
fun startDelete(message: HistoryMessageDto) {
if (!canDeleteMessage(message)) return
if (!canDeleteMessageOnServer(message)) return
_state.update { it.copy(messageAction = MessageActionState.Delete(message.id)) }
}
@@ -33,8 +33,6 @@ 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
@@ -122,8 +120,11 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* 恰恰可能不响。因此多给一个看得见、点得着的入口,长按照旧保留(气泡内边距、附件芯片、
* 图片这些不参与选字的区域按下去就是菜单)。
*
* 哪些动作可用由**产品口径**的纯函数给出([canEditMessage] / [canDeleteMessage]),
* 与 VM 里那道闸是同一份判据;两个都不成立时(未知 role)两个入口都不出现。
* 哪些动作可用由纯函数给出([canEditMessageOnServer] / [canDeleteMessageOnServer]:角色口径
* 加上「这条在服务端存在吗」),与 VM 里那道闸是同一份判据;两个都不成立时两个入口都不出现
* ——未知 `role` 是一档,**本端自铸的降级气泡**(`proactive-local:` 前缀,服务端根本没有这一行)
* 是另一档。后者只是不给入口:气泡的**选字**照旧(它是 `SelectionContainer` 那条独立的路,
* 与动作菜单无关)。
*
* @param imageBaseUrl 当前的服务器地址(`AuthManager.currentBaseUrl`)。只用来把历史图片里**绑死在
* 上传那一刻 host 上的同源绝对地址**换到现在的 host(`02/F6①`:`POST /upload` 的 `url` 按请求时的
@@ -157,8 +158,8 @@ fun MessageBubble(
parsed.ifEmpty { listOf(plainBubble(message.content)) }
}
val editable = canEditMessage(message)
val deletable = canDeleteMessage(message)
val editable = canEditMessageOnServer(message)
val deletable = canDeleteMessageOnServer(message)
val canAct = editable || deletable
// 每个气泡各自记住菜单开着没有:重建 / 滚动复用不会把上一个气泡的菜单带过来。
var menuOpen by remember(message.id) { mutableStateOf(false) }
@@ -0,0 +1,43 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.repository.canDeleteMessage
import top.yeij.cyrene.data.repository.canEditMessage
/**
* 这条消息在**服务端**有没有对应的一行(纯函数)。
*
* 判据只有一条:`id` 带不带 [PROACTIVE_LOCAL_ID_PREFIX]。那个前缀是本端给**降级气泡**
* (`proactive_message` 落库失败那一档,见 [ProactiveIngest.Bubble])自铸的,服务端的 id
* 永远不会长这样([proactiveIngestFor] 的 KDoc 说明了它是怎么来的)。
*
* 为什么这件事要单独成判据:一条消息「在服务端存在」是**打服务端那类动作的前提**。
* 拿一个服务端没见过的 id 去 `PATCH` / `DELETE`,最好的结果是 404,其余是一句用户看不懂的报错——
* 而这一切仅仅因为他长按了一下一条屏幕上看起来很正常的消息。
*
* **空 `id` 不在本判据的处理范围内**(今天的行为逐字不变):那属于「形状漂移」的坏帧,
* 是另一件事,不在本轮收口的范围里。
*/
fun isServerBackedMessage(message: HistoryMessageDto): Boolean =
!message.id.startsWith(PROACTIVE_LOCAL_ID_PREFIX)
/**
* 这条消息**能不能编辑**——在角色口径([canEditMessage])之上再叠一道「它在服务端存在吗」。
*
* 两道合起来才是界面与 VM 该用的判据:角色那一道回答「产品上允不允许改她说过的话」,
* 本判据回答「这一趟请求有没有意义」。任一道不成立都是**入口不出现**(fail-closed)。
*
* **只有会打服务端的动作走这里**:气泡里的文本选择 / 复制是纯本端的事
* (`SelectionContainer`,与动作菜单是两条独立的路),不归这两个函数管,也不该被它们挡住。
*/
fun canEditMessageOnServer(message: HistoryMessageDto): Boolean =
isServerBackedMessage(message) && canEditMessage(message)
/**
* 这条消息**能不能删除**——在角色口径([canDeleteMessage])之上再叠一道「它在服务端存在吗」。
*
* 降级气泡恰恰是个必须被挡住的例子:它是 `assistant` 行 ⇒ [canDeleteMessage] 会说「能删」,
* 但服务端根本没有这一行,删它只会用本地 id 打出一趟注定失败的请求(见 [isServerBackedMessage])。
*/
fun canDeleteMessageOnServer(message: HistoryMessageDto): Boolean =
isServerBackedMessage(message) && canDeleteMessage(message)
@@ -0,0 +1,79 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 空态这一刻该不该给一枚显式「重试」(W52/E)。
*
* 补的是那个没法挂任何状态变化的缺口:**REST 首屏失败、而 WS 从头到尾没断过**——
* 连接没变过(`onConnectionChanged` 不会再来一次)、也没有断线窗口要补,于是屏幕就停在空态。
* 修法是给一枚**用户显式动作**的重试(不是定时器:本项目从 W38 起就定死了「状态驱动,不按时间轮」)。
*
* 判据只看**屏幕上成立的三件事**(不在加载中 / 挂着一个错误 / 列表是空的),不声称「这就是首屏
* 失败」——那个事实只有 VM 里那个只许由 `loadFirstPage()` 写的字段说得准(见函数 KDoc)。
* 所以这里钉的是两组边界:
* - **该给**:什么都没显示、而且刚出过错 ⇒ 用户能做的下一件事就是再试一次;
* - **不该给**:正常空会话(服务端真的没消息——按钮按下去什么都不会变)、首屏还在飞、
* 以及屏幕上有东西(乐观气泡 / 流式气泡也算有东西)的那几档。
*/
class FirstPageRetryTest {
@Test
fun `an empty screen with an error offers the retry`() {
// 正题:首屏 REST 失败后的空屏(loading 已是 false、错误挂在顶部横幅上)。
assertTrue(shouldOfferRetryOnEmpty(loading = false, hasError = true, hasItems = false))
}
@Test
fun `a normal empty conversation offers nothing extra`() {
// 服务端真的没有消息:这时候「还没有消息」是**准确的**,多一枚按了不会变样的按钮只会让人
// 以为是它坏了。
assertFalse(shouldOfferRetryOnEmpty(loading = false, hasError = false, hasItems = false))
}
@Test
fun `a first page still in flight offers no retry`() {
// 还在飞时给重试 = 把同一趟请求发两遍(首屏只有一份实现,但两次并发调用仍然是两次请求)。
assertFalse(shouldOfferRetryOnEmpty(loading = true, hasError = false, hasItems = false))
// 加载中 + 旧错误(上一条错误还没被清掉)也照样不给:这一刻屏幕上写的是「加载中…」。
assertFalse(shouldOfferRetryOnEmpty(loading = true, hasError = true, hasItems = false))
}
@Test
fun `a screen with anything on it never offers the retry`() {
// 有东西(含乐观气泡、流式气泡)就不是空态:那几档屏幕上没有这枚按钮的位置,
// 而这个判据正是「只在列表空时成立」的那个出口。
for (loading in listOf(false, true)) {
for (hasError in listOf(false, true)) {
assertFalse(
"loading=$loading hasError=$hasError",
shouldOfferRetryOnEmpty(loading = loading, hasError = hasError, hasItems = true),
)
}
}
}
@Test
fun `every combination of the three facts is enumerated`() {
// 三个布尔一共八种组合,穷举一遍钉住「有且只有一格成立」——判据里将来多一个条件、
// 或者某一条写成 `||`,这里会立刻红。
val expected = setOf(
Triple(false, true, false), // 该给:空屏 + 有错 + 不在加载
)
val actual = buildSet {
for (loading in listOf(false, true)) {
for (hasError in listOf(false, true)) {
for (hasItems in listOf(false, true)) {
if (shouldOfferRetryOnEmpty(loading, hasError, hasItems)) {
add(Triple(loading, hasError, hasItems))
}
}
}
}
}
assertTrue("$actual", actual == expected)
}
}
@@ -0,0 +1,95 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.repository.canDeleteMessage
import top.yeij.cyrene.data.repository.canEditMessage
/**
* 「这条消息能不能给服务端动作」(W52/D:W50 留下的那条窄尾巴)。
*
* 钉住的核心是一句话:**降级气泡不许有编辑 / 删除入口**。它是 `proactive_message` 落库失败那一档
* 画出来的本地行(id 由本端自铸、带 `proactive-local:` 前缀),服务端**根本没有这一行**——
* 拿它的 id 去打 `PATCH` / `DELETE` 只会白摔一趟,用户看到的是一句莫名其妙的报错。
*
* 所以这里第一件事就是把**旧判据为什么不够**写进断言:角色口径([canDeleteMessage])对那个气泡
* 说的是「能删」(它就是一条 `assistant` 行),闸门必须来自「它在服务端存在吗」这一道。
*
* 另外几条是「别为了修一个洞开一个新洞」:服务端存在的消息**行为逐字不变**
* (user 能改能删、assistant 只能删、未知 role fail-closed),而前缀判定只认**开头**——
* 服务端的 id 里恰好含有这几个字(不是开头)时照旧放行。
*/
class MessageServerActionsTest {
private fun message(id: String, role: String) =
HistoryMessageDto(
id = id,
sessionId = MAIN_SESSION_ID,
role = role,
createdAt = 1_791_212_405_123L,
)
// —— 正题:降级气泡两个入口都没有 ——
@Test
fun `the local-only bubble gets no server action even though its role says it could be deleted`() {
// 这里刻意用**字面量**而不是常量:这一串就是线上那一档气泡的真实 id 形状
// (见 ProactiveIngestTest 对 `proactiveIngestFor` 的同一条断言),常量改了这里也要跟着响。
val bubble = message(id = "proactive-local:im-9", role = "assistant")
// 旧判据(只认 role)会说「能删」—— 这正是它此前长按能删、删了必报错的原因。
assertTrue(canDeleteMessage(bubble))
// 加上「服务端存在吗」这道闸之后,两个入口都不成立 ⇒ 动作菜单与那枚「⋯」都不会出现。
assertFalse(isServerBackedMessage(bubble))
assertFalse(canDeleteMessageOnServer(bubble))
assertFalse(canEditMessageOnServer(bubble))
}
@Test
fun `the local id gate does not depend on the role`() {
// 降级气泡今天恒是 assistant(服务端 persistProactiveToMain 定死),但判据不该把这件事
// 当成前提:前缀是**本端铸的**,凡带它的 id 服务端都不认识,与角色无关。
assertFalse(isServerBackedMessage(message("proactive-local:im-9", role = "user")))
assertFalse(canEditMessageOnServer(message("proactive-local:im-9", role = "user")))
}
@Test
fun `only the leading prefix is recognized`() {
// 判据是 startsWith,不是 contains:服务端的 id 里恰好出现这几个字(位置不对)时照旧放行,
// 否则一个合法的服务端 id 会因为「字符串里含有某段文本」而莫名失去入口。
assertTrue(isServerBackedMessage(message("1371-proactive-local", role = "assistant")))
assertTrue(isServerBackedMessage(message("msg-proactive-local:9", role = "assistant")))
}
// —— 服务端存在的消息:行为逐字不变 ——
@Test
fun `a server backed assistant row keeps delete and still has no edit`() {
val row = message(id = "1371", role = "assistant")
assertTrue(isServerBackedMessage(row))
assertTrue(canDeleteMessageOnServer(row))
assertFalse(canEditMessageOnServer(row))
}
@Test
fun `a server backed user row keeps both actions`() {
val row = message(id = "1372", role = "user")
assertTrue(canEditMessageOnServer(row))
assertTrue(canDeleteMessageOnServer(row))
}
@Test
fun `an unrecognized role is refused by both gates`() {
// fail-closed:未知 role 照旧两个都不给(这一条由 data 层的角色判据保证,这里把它钉在
// 组合之后的判据上,免得将来有人把两道闸的顺序写反)。
val row = message(id = "1373", role = "system")
assertFalse(canEditMessageOnServer(row))
assertFalse(canDeleteMessageOnServer(row))
assertFalse(canEditMessage(row))
}
}