4 Commits
Author SHA1 Message Date
AskaEth aace581fb0 merge: W52C —— 退出登录从小图标按钮改成带文字的危险行条目,图标换 Remove 2026-10-06 15:50:31 +08:00
AskaEth 27116fa434 merge: W52DE —— 降级气泡不再给删改入口(proactive-local: 前缀屏蔽服务端动作);空态补一枚显式「重试」 2026-10-06 15:50:30 +08:00
AskaEth 85818105ce feat(settings): 退出登录从小图标按钮改成带文字的危险行条目,图标换 Remove(W52C)
设置页最底部原来是一枚居中、无文字的图标按钮,图标用的还是「返回」箭头(MiuixIcons.Back),
真机上看着就是返回键——主人为此翻半天才找到登出在哪。这是本次改动的由来。

- 改成 Miuix 行条目(BasicComponent + 外层 PreferenceCard,与同页其它条目同形),文字「退出登录」,
  标题与图标都用 MiuixTheme.colorScheme.error 危险色。
- 图标换成 MiuixIcons.Remove:Miuix 里没有专门的登出/退出字形(Basic 只有 7 个图标),
  extended 里只有它画的是「箭头离开方框」,语义最接近「退出」;Back 是本条要修的毛病,
  Import 方向相反(箭头进方框=导入/登录),Delete/Refresh 之类会被误读成删号或重来。
- 图标 contentDescription 置 null:文字已经把这条读出来了,图标再报一遍只会让读屏重复。

行为一字不变:仍然点了就登出(onLogout 一路接到 CyreneApp 里的 authManager.logout()),
没有新增确认流程。
2026-10-06 15:48:54 +08:00
AskaEth 74a46bd2e3 fix(chat): 降级气泡不再给删改入口(W52/D);空态补一枚显式「重试」(W52/E)
## D —— W50 留下的窄尾巴:服务端不存在的那条气泡没有动作入口

`proactive_message` 落库失败那一档,客户端会画一条**只活在本端内存里**的气泡,它的 id 由本端自铸
(`proactive-local:` 前缀,见 `ProactiveIngest`)。而删除入口的判据 `canDeleteMessage` 只看 role ⇒
它是 assistant 行、长按能删 ⇒ 拿一个服务端没见过的 id 打 `DELETE`,最好的结果是 404。

修法按主人拍的口径收在 **UI 层**:新增 `ui/chat/MessageServerActions.kt`,在角色口径之上再叠一道
「这条消息在服务端存在吗」(判据只有 `id` 前缀这一条),组合成
`canEditMessageOnServer` / `canDeleteMessageOnServer`;气泡(菜单 + 「⋯」两个入口)与 VM 里那道闸
(`startEdit` / `startDelete`)**共用同一份**,不存在两套口径。
- **不动** `ChatDisplayItem`、数据模型与 `mergeMessages` 语义(原方案要加「是否服务端行」标志位,
  会连带动气泡渲染,超出范围)。
- 动作菜单里**只有**编辑与删除两个(一个 PATCH、一个 DELETE),都是打服务端的 ⇒ 降级气泡整菜单不出现,
  正是要的效果。**文本选择 / 复制不受影响**:它走 `SelectionContainer` 那条独立的路,`onLongPress`
  为 null 时反而不再与选字抢长按。

## E —— 首屏失败时给一个显式「重试」

登记在案的缺口:**REST 首屏失败、而 WS 从头到尾没断过**。这时没有任何状态变化可挂(连接没变过、
也没有断线窗口要补),屏幕会停在空态,直到用户切走再回来或上拉。

- `ChatViewModel.retryFirstPage()`:**只是转调** W51 提好的 `loadFirstPage()`(首屏唯一的一份实现,
  与连接恢复时补的那一次共用),**一个状态位都不自己写** —— `firstPageState` 的两个出口仍然只有那一处,
  W51 的 `reconnectPull` 判据依赖这条纪律。刻意**不是**定时器:项目从 W38 起就定死「状态驱动,不按时间轮」,
  而且反复重试会把「首屏失败」这个事实擦成 `Loading`。
- 空态那一档拆成两种真相(纯函数 `shouldOfferRetryOnEmpty`:不在加载中 + 挂着一个错误 + 列表为空):
  「还没有消息」照旧;「消息没能加载出来」配一枚 Miuix `TextButton`(`CenterHint` 多一个可选 `onRetry`)。
  其余时刻一个像素都不多画。

## 测试

- 新增 `MessageServerActionsTest`(6 条):钉住降级气泡两个入口都没有(并断言**旧判据确实说「能删」**,
  把「为什么需要这道闸」写进断言)、前缀与角色无关、只认**开头**的前缀、服务端行行为逐字不变、
  未知 role 仍 fail-closed。
- 新增 `FirstPageRetryTest`(5 条):正题一格成立;正常空会话 / 首屏在飞 / 屏幕上有东西(含乐观与流式气泡)
  都不给;三个布尔八种组合穷举,钉住「有且只有一格成立」。
- `./gradlew.bat --console=plain assembleDebug testDebugUnitTest`:**95 suite / 1216 tests / 0 失败 / 0 error**
  (基线 93 / 1205,+2 suite +11 tests,全为新增)。
- 真机验收未做(任务书禁碰 adb):按钮的位置与观感、以及长按降级气泡能选字,只能真机看。
2026-10-06 15:47:49 +08:00
7 changed files with 338 additions and 35 deletions

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.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text 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.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close import top.yukonga.miuix.kmp.icon.basic.Close
@@ -511,7 +512,20 @@ fun ChatScreen(
) { ) {
when { when {
state.loading && state.items.isEmpty() -> CenterHint("加载中…") 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( else -> LazyColumn(
state = listState, state = listState,
reverseLayout = true, 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 @Composable
private fun CenterHint(text: String) { private fun CenterHint(text: String, onRetry: (() -> Unit)? = null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text( Text(
text = text, text = text,
style = MiuixTheme.textStyles.main, style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary, 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.MessageRepository
import top.yeij.cyrene.data.repository.PagedMessages import top.yeij.cyrene.data.repository.PagedMessages
import top.yeij.cyrene.data.repository.applyMessageEdit 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.historyForSession
import top.yeij.cyrene.data.repository.mergeMessages import top.yeij.cyrene.data.repository.mergeMessages
import top.yeij.cyrene.data.repository.removeMessageById import top.yeij.cyrene.data.repository.removeMessageById
@@ -785,8 +783,10 @@ data class ChatUiState(
* 用的还是同一个键——**同一条消息无论在路上摔了几次,服务端都只认一条**。 * 用的还是同一个键——**同一条消息无论在路上摔了几次,服务端都只认一条**。
* *
* ## 单条消息的编辑 / 删除(D3) * ## 单条消息的编辑 / 删除(D3)
* 编辑**只对 `user` 消息开放**([canEditMessage]),删除对 `user` 与 `assistant` 都开放 * 编辑**只对 `user` 消息开放**,删除对 `user` 与 `assistant` 都开放,而且两条都要求**这条消息在
* ([canDeleteMessage])——判据是同一份纯函数,UI 与这里共用,不存在两套口径。 * 服务端存在**([canEditMessageOnServer] / [canDeleteMessageOnServer]:角色口径 + `id` 前缀那道闸,
* W52/D)——判据是同一份纯函数,UI 与这里共用,不存在两套口径。降级气泡因此两个入口都没有:
* 它服务端根本没有那一行,拿本地 id 打过去只会白摔一趟。
* *
* 两条路各自**只有一个落地出口**([applyMessageEdited] / [applyMessageDeleted]):本端自己发起的 * 两条路各自**只有一个落地出口**([applyMessageEdited] / [applyMessageDeleted]):本端自己发起的
* `PATCH` / `DELETE` 成功回执,与随后到达的 `message_edited` / `message_deleted` 广播,都调它们; * `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])。 * 那一刻决定**这一轮要不要补拉、补哪一段**(判据是纯函数 [reconnectPull])。
@@ -1123,8 +1141,8 @@ class ChatViewModel(
// —— D3:单条消息的编辑 / 删除 —— // —— D3:单条消息的编辑 / 删除 ——
/** /**
* 打开编辑弹层。**只对 `user` 消息开放**(口径见 [canEditMessage],UI 隐藏入口 + 这里再挡一次, * 打开编辑弹层。**只对服务端存在的 `user` 消息开放**(口径见 [canEditMessageOnServer],
* 判据是同一份纯函数)。 * UI 隐藏入口 + 这里再挡一次,判据是同一份纯函数)。
* *
* 初值走 [editableMessageOf]:编辑框里是**纯文本**(剥掉 `<p>` / `<action>` / `<narration>`), * 初值走 [editableMessageOf]:编辑框里是**纯文本**(剥掉 `<p>` / `<action>` / `<narration>`),
* 原文本来就是纯文本时逐字节不变。原文真带标记的话,那件事**不能不说**——所以 * 原文本来就是纯文本时逐字节不变。原文真带标记的话,那件事**不能不说**——所以
@@ -1133,7 +1151,7 @@ class ChatViewModel(
* 只算一次:`_state.update` 的 lambda 可能被重跑(CAS 重试),别把纯函数调用塞进它里面。 * 只算一次:`_state.update` 的 lambda 可能被重跑(CAS 重试),别把纯函数调用塞进它里面。
*/ */
fun startEdit(message: HistoryMessageDto) { fun startEdit(message: HistoryMessageDto) {
if (!canEditMessage(message)) return if (!canEditMessageOnServer(message)) return
val editable = editableMessageOf(message.content) val editable = editableMessageOf(message.content)
_state.update { _state.update {
it.copy( it.copy(
@@ -1146,9 +1164,12 @@ class ChatViewModel(
} }
} }
/** 打开删除的二次确认弹层(`user` 与 `assistant` 都开放,见 [canDeleteMessage])。 */ /**
* 打开删除的二次确认弹层(服务端存在的 `user` 与 `assistant` 消息都开放,
* 见 [canDeleteMessageOnServer]——**降级气泡不在其中**,服务端没有那一行,删它只会白打一趟)。
*/
fun startDelete(message: HistoryMessageDto) { fun startDelete(message: HistoryMessageDto) {
if (!canDeleteMessage(message)) return if (!canDeleteMessageOnServer(message)) return
_state.update { it.copy(messageAction = MessageActionState.Delete(message.id)) } _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.core.prefs.LocalAppPrefs
import top.yeij.cyrene.data.api.dto.DocumentRefDto import top.yeij.cyrene.data.api.dto.DocumentRefDto
import top.yeij.cyrene.data.api.dto.HistoryMessageDto 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.DocumentChipModel
import top.yeij.cyrene.domain.chat.DocumentChips import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument import top.yeij.cyrene.domain.chat.DraftDocument
@@ -122,8 +120,11 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* 恰恰可能不响。因此多给一个看得见、点得着的入口,长按照旧保留(气泡内边距、附件芯片、 * 恰恰可能不响。因此多给一个看得见、点得着的入口,长按照旧保留(气泡内边距、附件芯片、
* 图片这些不参与选字的区域按下去就是菜单)。 * 图片这些不参与选字的区域按下去就是菜单)。
* *
* 哪些动作可用由**产品口径**的纯函数给出([canEditMessage] / [canDeleteMessage]), * 哪些动作可用由纯函数给出([canEditMessageOnServer] / [canDeleteMessageOnServer]:角色口径
* 与 VM 里那道闸是同一份判据;两个都不成立时(未知 role)两个入口都不出现。 * 加上「这条在服务端存在吗」),与 VM 里那道闸是同一份判据;两个都不成立时两个入口都不出现
* ——未知 `role` 是一档,**本端自铸的降级气泡**(`proactive-local:` 前缀,服务端根本没有这一行)
* 是另一档。后者只是不给入口:气泡的**选字**照旧(它是 `SelectionContainer` 那条独立的路,
* 与动作菜单无关)。
* *
* @param imageBaseUrl 当前的服务器地址(`AuthManager.currentBaseUrl`)。只用来把历史图片里**绑死在 * @param imageBaseUrl 当前的服务器地址(`AuthManager.currentBaseUrl`)。只用来把历史图片里**绑死在
* 上传那一刻 host 上的同源绝对地址**换到现在的 host(`02/F6①`:`POST /upload` 的 `url` 按请求时的 * 上传那一刻 host 上的同源绝对地址**换到现在的 host(`02/F6①`:`POST /upload` 的 `url` 按请求时的
@@ -157,8 +158,8 @@ fun MessageBubble(
parsed.ifEmpty { listOf(plainBubble(message.content)) } parsed.ifEmpty { listOf(plainBubble(message.content)) }
} }
val editable = canEditMessage(message) val editable = canEditMessageOnServer(message)
val deletable = canDeleteMessage(message) val deletable = canDeleteMessageOnServer(message)
val canAct = editable || deletable val canAct = editable || deletable
// 每个气泡各自记住菜单开着没有:重建 / 滚动复用不会把上一个气泡的菜单带过来。 // 每个气泡各自记住菜单开着没有:重建 / 滚动复用不会把上一个气泡的菜单带过来。
var menuOpen by remember(message.id) { mutableStateOf(false) } 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)
@@ -1,11 +1,8 @@
package top.yeij.cyrene.ui.settings package top.yeij.cyrene.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -14,7 +11,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -25,15 +21,16 @@ import top.yeij.cyrene.core.prefs.InterruptMode
import top.yeij.cyrene.core.prefs.ThemeMode import top.yeij.cyrene.core.prefs.ThemeMode
import top.yeij.cyrene.ui.common.PreferenceCard import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Back
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
import top.yukonga.miuix.kmp.icon.extended.ChevronForward import top.yukonga.miuix.kmp.icon.extended.ChevronForward
import top.yukonga.miuix.kmp.icon.extended.Remove
import top.yukonga.miuix.kmp.preference.SwitchPreference import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -149,22 +146,23 @@ fun SettingsScreen(
} }
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
// 图标不能再用 Back(左箭头)——真机上它看着就是「返回」,这条曾经难找得主人翻了半天。
// Miuix 没有专门的登出/退出字形(Basic 只有 7 个图标),extended 里最贴切的是 Remove:
// 它画的是「箭头离开方框」,语义就是移出/离开。
PreferenceCard { PreferenceCard {
Row( BasicComponent(
modifier = Modifier title = "退出登录",
.fillMaxWidth() titleColor = BasicComponentDefaults.titleColor(color = MiuixTheme.colorScheme.error),
.padding(vertical = 8.dp), startAction = {
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onLogout) {
Icon( Icon(
imageVector = MiuixIcons.Back, imageVector = MiuixIcons.Remove,
contentDescription = "登出", // 文字已经把这条读出来了,图标再说一遍只会让读屏重复。
contentDescription = null,
tint = MiuixTheme.colorScheme.error, tint = MiuixTheme.colorScheme.error,
) )
} },
} onClick = onLogout,
)
} }
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
} }
@@ -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))
}
}