feat(sessions): 会话列表用服务端标题 + 置顶 + 最后消息预览(A4/D5/B3)
列表此前显示的是本地硬生成的「会话 N」,因为后端 title 恒空。后端后来做了
「首条用户消息自动命名」+ PATCH 改名/置顶 + lastMessage 预览,本轮接入,
并把本地那套序号机器整块删掉。
- data:SessionSummaryDto 补 pinned / lastMessage(均带默认值,老快照仍能解);
新增 UpdateSessionRequest / Response 与 @PATCH api/v1/sessions/{id}
- core/prefs:**整块退役**本地序号机器(order / KEY_ORDER / OrderSerializer /
decodeOrder / ensureOrder / 「会话 ?」)——有了服务端标题,本地序号永远不会一致,
且 id 没有序号时会渲染出字面的「会话 ?」
- 标题规则:服务端标题非空白即原样显示,否则兜底(主会话「与昔涟」/ 次级「新会话」)。
主会话刻意保留「与昔涟」而不采用服务端自己的「主会话」
- 顺序**完全信任服务端**(pinned DESC, main DESC, created_at ASC),客户端不重排;
因此置顶的次级会排在主会话之前——顺带修正 SessionDto 里「主会话恒首位」的过时注释
- 置顶:主会话与次级都可置顶(契约允许);先就地翻转拿即时反馈 → PATCH →
成功后静默重拉(服务端才是顺序权威,快照顺带修正)→ 失败回滚并报错
- 改名:长按行打开对话框(预填当前标题 + n/80 计数),不做乐观更新(服务端会 trim 后
回写,等静默重拉取权威值);**不提供「恢复自动命名」**——服务端无法表达(空白即 400)
- **刷新触发(前置条件,不是锦上添花)**:列表此前只在 VM 初始化 / 新建 / 删除时拉取,
所以自动标题与预览要重启 App 才可见。现改为**打开抽屉时静默刷新**,
且不闪「同步中…」横幅
- 顺带修同文件里两个既有缺陷:① **快照的指纹闸门其实没实现**(fingerprint 写了却从不
比较、clear() 是死代码)→ 现按 baseUrl+username 指纹过滤,并把 clear() 接到登出
(否则换账号后第一帧会闪上个账号的会话列表);已实测确认指纹与写入时逐字节一致,
不会误丢老快照。② 预览剥标签时旁白与正文会粘连(看截图发现的排版瑕疵)→
标签改替成空格再折叠空白
测试:175 全绿(153 → +22)。含真实抓取响应体的 DTO 契约测试、标题兜底与预览规则的
纯函数测试、指纹闸门测试。
This commit is contained in:
1 parent
f5576540b4
commit
c09a21136c
10 files changed
+654
-71
No files matched your search
@@ -52,12 +52,20 @@ fun ChatHomeScreen(
|
||||
|
||||
else -> MiuixDrawer(
|
||||
open = drawerOpen,
|
||||
onOpenChange = { drawerOpen = it },
|
||||
// 抽屉只会被外部入口打开(MiuixDrawer 的 onOpenChange 只回报关闭),
|
||||
// 所以这里不用管「打开」分支。
|
||||
onOpenChange = { open -> drawerOpen = open },
|
||||
content = {
|
||||
ChatScreen(
|
||||
container = container,
|
||||
sessionId = state.activeSessionId,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
onOpenDrawer = {
|
||||
drawerOpen = true
|
||||
// 抽屉打开时静默重拉:列表只在 init / 新建 / 删除时取过,而服务端的
|
||||
// 自动标题与置顶顺序**没有任何 WS 帧**会推过来,只能靠这里对齐。
|
||||
// 静默是为了不闪「同步中…」——屏幕上本来就有内容。
|
||||
sessionsViewModel.refresh(silent = true)
|
||||
},
|
||||
onLogout = onLogout,
|
||||
)
|
||||
},
|
||||
@@ -70,6 +78,8 @@ fun ChatHomeScreen(
|
||||
},
|
||||
onCreate = sessionsViewModel::createSession,
|
||||
onDelete = { sessionsViewModel.deleteSession(it.id) },
|
||||
onTogglePin = { sessionsViewModel.setPinned(it.id, !it.pinned) },
|
||||
onRename = { sessionId, title -> sessionsViewModel.renameSession(sessionId, title) },
|
||||
onOpenIot = { /* P2:IoT 页面 */ },
|
||||
onOpenAdmin = {
|
||||
drawerOpen = false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package top.yeij.cyrene.ui.sessions
|
||||
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
@@ -29,23 +29,34 @@ import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
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.extended.Add
|
||||
import top.yukonga.miuix.kmp.icon.extended.Delete
|
||||
import top.yukonga.miuix.kmp.icon.extended.Pin
|
||||
import top.yukonga.miuix.kmp.icon.extended.Unpin
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 侧边栏内容:会话列表 + IoT/设置入口。列表项为 MD3 小圆角卡片。 */
|
||||
/**
|
||||
* 侧边栏内容:会话列表 + IoT/设置入口。列表项为 MD3 小圆角卡片。
|
||||
*
|
||||
* 行内交互:单击切换会话、**长按重命名**、行尾置顶/取消置顶;删除只对次级会话开放。
|
||||
* 标题是服务端的自动标题(首条用户消息生成),没有就落到兜底标题。
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.SessionDrawerContent(
|
||||
state: SessionsUiState,
|
||||
onSelect: (String) -> Unit,
|
||||
onCreate: () -> Unit,
|
||||
onDelete: (SessionItem) -> Unit,
|
||||
onTogglePin: (SessionItem) -> Unit,
|
||||
onRename: (String, String) -> Unit,
|
||||
onOpenIot: () -> Unit,
|
||||
onOpenAdmin: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
var pendingDelete by remember { mutableStateOf<SessionItem?>(null) }
|
||||
var pendingRename by remember { mutableStateOf<SessionItem?>(null) }
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) {
|
||||
Text("Cyrene", style = MiuixTheme.textStyles.title2)
|
||||
@@ -77,11 +88,22 @@ fun ColumnScope.SessionDrawerContent(
|
||||
session = session,
|
||||
active = session.id == state.activeSessionId,
|
||||
onClick = { onSelect(session.id) },
|
||||
onTogglePin = { onTogglePin(session) },
|
||||
onRename = { pendingRename = session },
|
||||
onDelete = { pendingDelete = session },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
text = state.error.orEmpty(),
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.fromCache && state.syncedAt > 0) {
|
||||
Text(
|
||||
text = "网络异常,显示 ${relativeTime(state.syncedAt)} 的结果",
|
||||
@@ -118,19 +140,38 @@ fun ColumnScope.SessionDrawerContent(
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
}
|
||||
|
||||
pendingRename?.let { target ->
|
||||
RenameDialog(
|
||||
initial = target.title,
|
||||
onConfirm = { title ->
|
||||
onRename(target.id, title)
|
||||
pendingRename = null
|
||||
},
|
||||
onDismiss = { pendingRename = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按,
|
||||
* `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable,
|
||||
* 子级会先吃掉按下事件,长按就永远不会触发)。
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionRow(
|
||||
session: SessionItem,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onTogglePin: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onRename),
|
||||
title = session.title,
|
||||
onClick = onClick,
|
||||
summary = session.lastMessage,
|
||||
endActions = {
|
||||
if (active) {
|
||||
Text(
|
||||
@@ -140,6 +181,17 @@ private fun SessionRow(
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onTogglePin) {
|
||||
Icon(
|
||||
imageVector = if (session.pinned) MiuixIcons.Unpin else MiuixIcons.Pin,
|
||||
contentDescription = if (session.pinned) "取消置顶" else "置顶",
|
||||
tint = if (session.pinned) {
|
||||
MiuixTheme.colorScheme.primary
|
||||
} else {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!session.isMain) {
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
@@ -154,6 +206,59 @@ private fun SessionRow(
|
||||
}
|
||||
}
|
||||
|
||||
/** 重命名对话框:服务端不接受空标题(400)也不接受超过 80 个字符,两者都在这里挡住。 */
|
||||
@Composable
|
||||
private fun RenameDialog(
|
||||
initial: String,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var name by remember(initial) { mutableStateOf(initial) }
|
||||
val trimmed = name.trim()
|
||||
val used = trimmed.runes()
|
||||
val tooLong = used > TITLE_MAX_RUNES
|
||||
val canSave = trimmed.isNotEmpty() && !tooLong
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
|
||||
.padding(20.dp),
|
||||
) {
|
||||
Text("重命名会话", style = MiuixTheme.textStyles.title3)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = "会话名称",
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "$used / $TITLE_MAX_RUNES",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = if (tooLong) {
|
||||
MiuixTheme.colorScheme.error
|
||||
} else {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton("取消", onClick = onDismiss)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(
|
||||
text = "保存",
|
||||
onClick = { onConfirm(trimmed) },
|
||||
enabled = canSave,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmDialog(
|
||||
title: String,
|
||||
@@ -186,6 +291,12 @@ private fun ConfirmDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/** 服务端 `PATCH /sessions/{id}` 的上限:超过 80 个字符直接 400。 */
|
||||
private const val TITLE_MAX_RUNES = 80
|
||||
|
||||
/** 按 Unicode 码点计数,与服务端 Go 的「rune」口径一致(代理对算一个)。 */
|
||||
private fun String.runes(): Int = codePointCount(0, length)
|
||||
|
||||
private fun relativeTime(timestamp: Long): String {
|
||||
val minutes = (System.currentTimeMillis() - timestamp) / 60_000
|
||||
return when {
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
|
||||
@@ -16,6 +17,9 @@ data class SessionItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val isMain: Boolean,
|
||||
val pinned: Boolean,
|
||||
/** 最后一条消息的一行摘要;会话还没有消息时为 null。 */
|
||||
val lastMessage: String?,
|
||||
)
|
||||
|
||||
data class SessionsUiState(
|
||||
@@ -30,7 +34,9 @@ data class SessionsUiState(
|
||||
|
||||
/**
|
||||
* 会话列表(§3.1):
|
||||
* 启动先用本地快照渲染,再拉服务端;成功则整体替换并回写快照,失败则保留快照并提示。
|
||||
* 启动先用本地快照渲染(指纹不匹配就不用),再拉服务端;成功则整体替换并回写快照,
|
||||
* 失败则保留快照并提示。顺序完全信任服务端(`pinned DESC, main DESC, created_at ASC`),
|
||||
* 客户端不重排。
|
||||
*/
|
||||
class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
@@ -41,29 +47,51 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val fingerprint = fingerprint()
|
||||
val cached = prefs.data.first()
|
||||
val snapshot = cached.snapshotFor(fingerprint)
|
||||
_state.update {
|
||||
it.copy(
|
||||
sessions = cached.snapshot.map { dto -> dto.toItem(cached.order) },
|
||||
activeSessionId = cached.activeSessionId,
|
||||
fromCache = cached.snapshot.isNotEmpty(),
|
||||
syncedAt = cached.snapshotAt,
|
||||
sessions = snapshot.map { dto -> dto.toItem() },
|
||||
activeSessionId = if (snapshot.isEmpty()) {
|
||||
SessionSummaryDto.MAIN_SESSION_ID
|
||||
} else {
|
||||
cached.activeSessionId
|
||||
},
|
||||
fromCache = snapshot.isNotEmpty(),
|
||||
syncedAt = if (snapshot.isEmpty()) 0L else cached.snapshotAt,
|
||||
)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
// 登出时把快照一起清掉(先例:EnumCatalogViewModel 在登出时清枚举快照)——
|
||||
// 否则换账号后第一帧会闪一份上个账号的会话列表。
|
||||
viewModelScope.launch {
|
||||
container.authManager.state.collect { auth ->
|
||||
if (auth == SessionState.LoggedOut) {
|
||||
prefs.clear()
|
||||
_state.value = SessionsUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
/**
|
||||
* 拉取服务端列表。
|
||||
*
|
||||
* @param silent 静默刷新:**不动**“同步中…”横幅。用于界面上已经有内容、
|
||||
* 只是去对齐服务端状态的场景(抽屉打开、改名/置顶之后),避免闪一下横幅。
|
||||
*/
|
||||
fun refresh(silent: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(loading = true) }
|
||||
if (!silent) _state.update { it.copy(loading = true) }
|
||||
container.sessionRepository.listSessions()
|
||||
.onSuccess { list ->
|
||||
prefs.saveSnapshot(list, fingerprint())
|
||||
val cached = prefs.data.first()
|
||||
_state.update {
|
||||
it.copy(
|
||||
sessions = list.map { dto -> dto.toItem(cached.order) },
|
||||
sessions = list.map { dto -> dto.toItem() },
|
||||
loading = false,
|
||||
fromCache = false,
|
||||
syncedAt = cached.snapshotAt,
|
||||
@@ -114,17 +142,82 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 置顶 / 取消置顶。主会话也能置顶(契约允许)。
|
||||
*
|
||||
* 先就地翻转 `pinned` 拿到即时反馈,再发 PATCH;成功后**静默重拉**——
|
||||
* 服务端才是顺序的权威(置顶会把会话排到最前),快照也顺带被修正。
|
||||
* 失败则回滚到调用前的列表并报错。
|
||||
*/
|
||||
fun setPinned(sessionId: String, pinned: Boolean) {
|
||||
val before = _state.value.sessions
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
sessions = state.sessions.map { if (it.id == sessionId) it.copy(pinned = pinned) else it },
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
container.sessionRepository.updateSession(sessionId, pinned = pinned)
|
||||
.onSuccess { refresh(silent = true) }
|
||||
.onFailure { e ->
|
||||
_state.update { it.copy(sessions = before, error = e.userMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名。不做乐观更新:服务端会 trim 后回写,等静默重拉拿到权威标题。
|
||||
* 调用方必须保证 [title] 非空白且不超过 80 个字符(服务端会 400)。
|
||||
*/
|
||||
fun renameSession(sessionId: String, title: String) {
|
||||
viewModelScope.launch {
|
||||
container.sessionRepository.updateSession(sessionId, title = title.trim())
|
||||
.onSuccess { refresh(silent = true) }
|
||||
.onFailure { e -> _state.update { it.copy(error = e.userMessage()) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() = _state.update { it.copy(error = null) }
|
||||
|
||||
private fun fingerprint(): String =
|
||||
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
|
||||
}
|
||||
|
||||
private fun SessionSummaryDto.toItem(order: Map<String, Int>): SessionItem = SessionItem(
|
||||
internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
|
||||
id = id,
|
||||
title = if (isMain) "与昔涟" else "会话 ${order[id] ?: "?"}",
|
||||
title = displayTitle(),
|
||||
isMain = isMain,
|
||||
pinned = pinned,
|
||||
lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
/**
|
||||
* 列表行标题:优先用服务端的自动标题(首条用户消息生成),
|
||||
* 会话还没有用户消息时 `title` 键整个缺席(或为空白),此时才兜底。
|
||||
*
|
||||
* 主会话兜底用「与昔涟」而不是服务端的「主会话」——这是个人应用,标题更贴切。
|
||||
*/
|
||||
fun SessionSummaryDto.displayTitle(): String = title?.takeIf { it.isNotBlank() }
|
||||
?: if (isMain) MAIN_SESSION_TITLE else NEW_SESSION_TITLE
|
||||
|
||||
internal const val MAIN_SESSION_TITLE = "与昔涟"
|
||||
internal const val NEW_SESSION_TITLE = "新会话"
|
||||
|
||||
/**
|
||||
* 预览文本:服务端的 `content` 只折叠空白 + 截断到 60 字,`<p>` / `<action>` / `<narration>`
|
||||
* 标记**原样保留**(截断还可能把标签截在半路),直接显示会露出标签,所以这里剥掉标签、折成一行。
|
||||
*
|
||||
* 注意标签替成**空格**而不是空串:旁白紧贴正文时(`…凑近了一点<action>悄悄戳了戳你</action>今天下午…`),
|
||||
* 替成空串会把两侧的字粘成一个词。
|
||||
*/
|
||||
internal fun String.toPreviewText(): String = replace(PREVIEW_TAG_RE, " ")
|
||||
.replace(WHITESPACE_RE, " ")
|
||||
.trim()
|
||||
|
||||
/** 只剥本应用认识的三种标记;`\b` 保证 `<pre>` 这类同前缀标签不会被误伤。 */
|
||||
private val PREVIEW_TAG_RE = Regex("</?\\s*(?:p|action|narration)\\b[^>]*>")
|
||||
|
||||
private val WHITESPACE_RE = Regex("\\s+")
|
||||
|
||||
private fun Throwable.userMessage(): String =
|
||||
(this as? CyreneException)?.error?.userMessage ?: "操作失败,请稍后重试"
|
||||
Reference in new issue
Block a user