Commit cbe120203aa241922f85ce4c96dce7b80dc895a4

Authored by yibo
1 parent fd27a512

feat(chat): 消息气泡长按菜单(复制/修改/追问/选取文字/删除)

- 长按任意消息气泡弹出 DropdownMenu
- 复制:全文写入剪贴板
- 修改:仅用户消息,回填到输入框供编辑重发
- 追问:引用消息前 60 字填入输入框(> 引用\n\n 格式)
- 选取文字:AlertDialog + SelectionContainer,系统长按选文
- 删除:确认 Dialog 后删单条消息(ChatDao.deleteMessage)
- 流式消息(inProgress)长按无响应,防误触

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt
1 1 package com.lemon.lifelog
2 2  
  3 +import androidx.compose.foundation.ExperimentalFoundationApi
3 4 import androidx.compose.foundation.clickable
  5 +import androidx.compose.foundation.combinedClickable
4 6 import androidx.compose.foundation.layout.Arrangement
5 7 import androidx.compose.foundation.layout.Box
6 8 import androidx.compose.foundation.layout.Column
... ... @@ -52,7 +54,10 @@ import androidx.compose.runtime.rememberCoroutineScope
52 54 import androidx.compose.runtime.setValue
53 55 import androidx.compose.ui.Alignment
54 56 import androidx.compose.ui.Modifier
  57 +import androidx.compose.foundation.text.selection.SelectionContainer
  58 +import androidx.compose.ui.platform.LocalClipboardManager
55 59 import androidx.compose.ui.platform.LocalContext
  60 +import androidx.compose.ui.text.AnnotatedString
56 61 import androidx.compose.ui.text.style.TextOverflow
57 62 import androidx.compose.ui.unit.dp
58 63 import com.lemon.lifelog.chat.ChatConfig
... ... @@ -177,15 +182,36 @@ internal fun ChatTab(repo: Repository) {
177 182 )
178 183 }
179 184 } else {
  185 + val clipboard = LocalClipboardManager.current
180 186 LazyColumn(
181 187 state = listState,
182 188 modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
183 189 verticalArrangement = Arrangement.spacedBy(8.dp),
184 190 ) {
185   - items(messages, key = { it.id }) { m -> MessageBubble(m.role, m.content) }
  191 + items(messages, key = { it.id }) { m ->
  192 + MessageBubble(
  193 + role = m.role,
  194 + content = m.content,
  195 + onCopy = { clipboard.setText(AnnotatedString(m.content)) },
  196 + onEdit = if (m.role == "user") {{ vm.input = m.content }} else null,
  197 + onFollowUp = {
  198 + val preview = m.content.take(60).let { s -> if (m.content.length > 60) "$s…" else s }
  199 + vm.input = "> $preview\n\n"
  200 + },
  201 + onDelete = { vm.deleteMessage(m.id) },
  202 + )
  203 + }
186 204 if (vm.streaming) {
187 205 item(key = "streaming") {
188   - MessageBubble("assistant", vm.streamingText.ifEmpty { "…" }, inProgress = true)
  206 + MessageBubble(
  207 + role = "assistant",
  208 + content = vm.streamingText.ifEmpty { "…" },
  209 + inProgress = true,
  210 + onCopy = { clipboard.setText(AnnotatedString(vm.streamingText)) },
  211 + onEdit = null,
  212 + onFollowUp = {},
  213 + onDelete = {},
  214 + )
189 215 }
190 216 }
191 217 }
... ... @@ -286,26 +312,94 @@ private fun TargetSelector(targets: List&lt;ChatTarget&gt;, selected: ChatTarget, onSe
286 312 }
287 313 }
288 314  
  315 +@OptIn(ExperimentalFoundationApi::class)
289 316 @Composable
290   -private fun MessageBubble(role: String, content: String, inProgress: Boolean = false) {
  317 +private fun MessageBubble(
  318 + role: String,
  319 + content: String,
  320 + inProgress: Boolean = false,
  321 + onCopy: () -> Unit,
  322 + onEdit: (() -> Unit)?, // null = 不适用(AI 消息)
  323 + onFollowUp: () -> Unit,
  324 + onDelete: () -> Unit,
  325 +) {
291 326 val isUser = role == "user"
292 327 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
  328 + var showMenu by remember { mutableStateOf(false) }
  329 + var showSelectDialog by remember { mutableStateOf(false) }
  330 + var showDeleteConfirm by remember { mutableStateOf(false) }
  331 +
293 332 Row(
294 333 modifier = Modifier.fillMaxWidth(),
295 334 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
296 335 ) {
297   - Surface(
298   - color = bubbleColor,
299   - shape = RoundedCornerShape(14.dp),
300   - modifier = Modifier.widthIn(max = 300.dp),
301   - ) {
302   - Text(
303   - content + if (inProgress) " ▍" else "",
304   - style = MaterialTheme.typography.bodyMedium,
305   - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
306   - )
  336 + Box {
  337 + Surface(
  338 + color = bubbleColor,
  339 + shape = RoundedCornerShape(14.dp),
  340 + modifier = Modifier
  341 + .widthIn(max = 300.dp)
  342 + .combinedClickable(
  343 + onClick = {},
  344 + onLongClick = { if (!inProgress) showMenu = true },
  345 + ),
  346 + ) {
  347 + Text(
  348 + content + if (inProgress) " ▍" else "",
  349 + style = MaterialTheme.typography.bodyMedium,
  350 + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
  351 + )
  352 + }
  353 +
  354 + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
  355 + DropdownMenuItem(
  356 + text = { Text("复制") },
  357 + onClick = { onCopy(); showMenu = false },
  358 + )
  359 + if (isUser && onEdit != null) {
  360 + DropdownMenuItem(
  361 + text = { Text("修改") },
  362 + onClick = { onEdit(); showMenu = false },
  363 + )
  364 + }
  365 + DropdownMenuItem(
  366 + text = { Text("追问") },
  367 + onClick = { onFollowUp(); showMenu = false },
  368 + )
  369 + DropdownMenuItem(
  370 + text = { Text("选取文字") },
  371 + onClick = { showSelectDialog = true; showMenu = false },
  372 + )
  373 + DropdownMenuItem(
  374 + text = { Text("删除", color = MaterialTheme.colorScheme.error) },
  375 + onClick = { showDeleteConfirm = true; showMenu = false },
  376 + )
  377 + }
307 378 }
308 379 }
  380 +
  381 + if (showSelectDialog) {
  382 + AlertDialog(
  383 + onDismissRequest = { showSelectDialog = false },
  384 + title = { Text(if (isUser) "我的消息" else "AI 回复") },
  385 + text = { SelectionContainer { Text(content, style = MaterialTheme.typography.bodyMedium) } },
  386 + confirmButton = { TextButton(onClick = { showSelectDialog = false }) { Text("关闭") } },
  387 + )
  388 + }
  389 +
  390 + if (showDeleteConfirm) {
  391 + AlertDialog(
  392 + onDismissRequest = { showDeleteConfirm = false },
  393 + title = { Text("删除消息") },
  394 + text = { Text("确认删除这条消息?") },
  395 + confirmButton = {
  396 + TextButton(onClick = { onDelete(); showDeleteConfirm = false }) {
  397 + Text("删除", color = MaterialTheme.colorScheme.error)
  398 + }
  399 + },
  400 + dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("取消") } },
  401 + )
  402 + }
309 403 }
310 404  
311 405 @Composable
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt
... ... @@ -97,6 +97,12 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
97 97 }
98 98 }
99 99  
  100 + fun deleteMessage(id: Long) {
  101 + viewModelScope.launch {
  102 + withContext(Dispatchers.IO) { repo.chats.deleteMessage(id) }
  103 + }
  104 + }
  105 +
100 106 fun send() {
101 107 val text = input.trim()
102 108 if (text.isEmpty() || streaming) return
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
... ... @@ -1054,6 +1054,9 @@ interface ChatDao {
1054 1054 @Query("DELETE FROM chat_messages WHERE conversationId = :cid")
1055 1055 suspend fun deleteMessages(cid: Long)
1056 1056  
  1057 + @Query("DELETE FROM chat_messages WHERE id = :id")
  1058 + suspend fun deleteMessage(id: Long)
  1059 +
1057 1060 @Query("DELETE FROM chat_conversations WHERE id = :id")
1058 1061 suspend fun deleteConversation(id: Long)
1059 1062 }
... ...