Commit cbe120203aa241922f85ce4c96dce7b80dc895a4
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>
Showing
3 changed files
with
116 additions
and
13 deletions
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<ChatTarget>, 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 | } | ... | ... |