From cbe120203aa241922f85ce4c96dce7b80dc895a4 Mon Sep 17 00:00:00 2001 From: yibo Date: Thu, 11 Jun 2026 00:37:52 +0800 Subject: [PATCH] feat(chat): 消息气泡长按菜单(复制/修改/追问/选取文字/删除) --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt | 120 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt | 6 ++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 3 +++ 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt index 2e737e0..35c0ebb 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt @@ -1,6 +1,8 @@ package com.lemon.lifelog +import androidx.compose.foundation.ExperimentalFoundationApi 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 @@ -52,7 +54,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.lemon.lifelog.chat.ChatConfig @@ -177,15 +182,36 @@ internal fun ChatTab(repo: Repository) { ) } } else { + val clipboard = LocalClipboardManager.current LazyColumn( state = listState, modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(messages, key = { it.id }) { m -> MessageBubble(m.role, m.content) } + items(messages, key = { it.id }) { m -> + MessageBubble( + role = m.role, + content = m.content, + onCopy = { clipboard.setText(AnnotatedString(m.content)) }, + onEdit = if (m.role == "user") {{ vm.input = m.content }} else null, + onFollowUp = { + val preview = m.content.take(60).let { s -> if (m.content.length > 60) "$s…" else s } + vm.input = "> $preview\n\n" + }, + onDelete = { vm.deleteMessage(m.id) }, + ) + } if (vm.streaming) { item(key = "streaming") { - MessageBubble("assistant", vm.streamingText.ifEmpty { "…" }, inProgress = true) + MessageBubble( + role = "assistant", + content = vm.streamingText.ifEmpty { "…" }, + inProgress = true, + onCopy = { clipboard.setText(AnnotatedString(vm.streamingText)) }, + onEdit = null, + onFollowUp = {}, + onDelete = {}, + ) } } } @@ -286,26 +312,94 @@ private fun TargetSelector(targets: List, selected: ChatTarget, onSe } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun MessageBubble(role: String, content: String, inProgress: Boolean = false) { +private fun MessageBubble( + role: String, + content: String, + inProgress: Boolean = false, + onCopy: () -> Unit, + onEdit: (() -> Unit)?, // null = 不适用(AI 消息) + onFollowUp: () -> Unit, + onDelete: () -> Unit, +) { val isUser = role == "user" val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant + var showMenu by remember { mutableStateOf(false) } + var showSelectDialog by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, ) { - Surface( - color = bubbleColor, - shape = RoundedCornerShape(14.dp), - modifier = Modifier.widthIn(max = 300.dp), - ) { - Text( - content + if (inProgress) " ▍" else "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) + Box { + Surface( + color = bubbleColor, + shape = RoundedCornerShape(14.dp), + modifier = Modifier + .widthIn(max = 300.dp) + .combinedClickable( + onClick = {}, + onLongClick = { if (!inProgress) showMenu = true }, + ), + ) { + Text( + content + if (inProgress) " ▍" else "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenuItem( + text = { Text("复制") }, + onClick = { onCopy(); showMenu = false }, + ) + if (isUser && onEdit != null) { + DropdownMenuItem( + text = { Text("修改") }, + onClick = { onEdit(); showMenu = false }, + ) + } + DropdownMenuItem( + text = { Text("追问") }, + onClick = { onFollowUp(); showMenu = false }, + ) + DropdownMenuItem( + text = { Text("选取文字") }, + onClick = { showSelectDialog = true; showMenu = false }, + ) + DropdownMenuItem( + text = { Text("删除", color = MaterialTheme.colorScheme.error) }, + onClick = { showDeleteConfirm = true; showMenu = false }, + ) + } } } + + if (showSelectDialog) { + AlertDialog( + onDismissRequest = { showSelectDialog = false }, + title = { Text(if (isUser) "我的消息" else "AI 回复") }, + text = { SelectionContainer { Text(content, style = MaterialTheme.typography.bodyMedium) } }, + confirmButton = { TextButton(onClick = { showSelectDialog = false }) { Text("关闭") } }, + ) + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("删除消息") }, + text = { Text("确认删除这条消息?") }, + confirmButton = { + TextButton(onClick = { onDelete(); showDeleteConfirm = false }) { + Text("删除", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("取消") } }, + ) + } } @Composable diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt index 9075862..e7e4c14 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt @@ -97,6 +97,12 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { } } + fun deleteMessage(id: Long) { + viewModelScope.launch { + withContext(Dispatchers.IO) { repo.chats.deleteMessage(id) } + } + } + fun send() { val text = input.trim() if (text.isEmpty() || streaming) return diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 20a0879..b4710ad 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -1054,6 +1054,9 @@ interface ChatDao { @Query("DELETE FROM chat_messages WHERE conversationId = :cid") suspend fun deleteMessages(cid: Long) + @Query("DELETE FROM chat_messages WHERE id = :id") + suspend fun deleteMessage(id: Long) + @Query("DELETE FROM chat_conversations WHERE id = :id") suspend fun deleteConversation(id: Long) } -- libgit2 0.21.4