Commit 44c68767aec7f93c8ae81a8b62c1d6bc7389e210
1 parent
30826007
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 | package com.lemon.lifelog | 1 | package com.lemon.lifelog |
| 2 | 2 | ||
| 3 | +import androidx.compose.foundation.ExperimentalFoundationApi | ||
| 3 | import androidx.compose.foundation.clickable | 4 | import androidx.compose.foundation.clickable |
| 5 | +import androidx.compose.foundation.combinedClickable | ||
| 4 | import androidx.compose.foundation.layout.Arrangement | 6 | import androidx.compose.foundation.layout.Arrangement |
| 5 | import androidx.compose.foundation.layout.Box | 7 | import androidx.compose.foundation.layout.Box |
| 6 | import androidx.compose.foundation.layout.Column | 8 | import androidx.compose.foundation.layout.Column |
| @@ -52,7 +54,10 @@ import androidx.compose.runtime.rememberCoroutineScope | @@ -52,7 +54,10 @@ import androidx.compose.runtime.rememberCoroutineScope | ||
| 52 | import androidx.compose.runtime.setValue | 54 | import androidx.compose.runtime.setValue |
| 53 | import androidx.compose.ui.Alignment | 55 | import androidx.compose.ui.Alignment |
| 54 | import androidx.compose.ui.Modifier | 56 | import androidx.compose.ui.Modifier |
| 57 | +import androidx.compose.foundation.text.selection.SelectionContainer | ||
| 58 | +import androidx.compose.ui.platform.LocalClipboardManager | ||
| 55 | import androidx.compose.ui.platform.LocalContext | 59 | import androidx.compose.ui.platform.LocalContext |
| 60 | +import androidx.compose.ui.text.AnnotatedString | ||
| 56 | import androidx.compose.ui.text.style.TextOverflow | 61 | import androidx.compose.ui.text.style.TextOverflow |
| 57 | import androidx.compose.ui.unit.dp | 62 | import androidx.compose.ui.unit.dp |
| 58 | import com.lemon.lifelog.chat.ChatConfig | 63 | import com.lemon.lifelog.chat.ChatConfig |
| @@ -177,15 +182,36 @@ internal fun ChatTab(repo: Repository) { | @@ -177,15 +182,36 @@ internal fun ChatTab(repo: Repository) { | ||
| 177 | ) | 182 | ) |
| 178 | } | 183 | } |
| 179 | } else { | 184 | } else { |
| 185 | + val clipboard = LocalClipboardManager.current | ||
| 180 | LazyColumn( | 186 | LazyColumn( |
| 181 | state = listState, | 187 | state = listState, |
| 182 | modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), | 188 | modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), |
| 183 | verticalArrangement = Arrangement.spacedBy(8.dp), | 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 | if (vm.streaming) { | 204 | if (vm.streaming) { |
| 187 | item(key = "streaming") { | 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,26 +312,94 @@ private fun TargetSelector(targets: List<ChatTarget>, selected: ChatTarget, onSe | ||
| 286 | } | 312 | } |
| 287 | } | 313 | } |
| 288 | 314 | ||
| 315 | +@OptIn(ExperimentalFoundationApi::class) | ||
| 289 | @Composable | 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 | val isUser = role == "user" | 326 | val isUser = role == "user" |
| 292 | val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant | 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 | Row( | 332 | Row( |
| 294 | modifier = Modifier.fillMaxWidth(), | 333 | modifier = Modifier.fillMaxWidth(), |
| 295 | horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, | 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 | @Composable | 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,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 | fun send() { | 106 | fun send() { |
| 101 | val text = input.trim() | 107 | val text = input.trim() |
| 102 | if (text.isEmpty() || streaming) return | 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,6 +1054,9 @@ interface ChatDao { | ||
| 1054 | @Query("DELETE FROM chat_messages WHERE conversationId = :cid") | 1054 | @Query("DELETE FROM chat_messages WHERE conversationId = :cid") |
| 1055 | suspend fun deleteMessages(cid: Long) | 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 | @Query("DELETE FROM chat_conversations WHERE id = :id") | 1060 | @Query("DELETE FROM chat_conversations WHERE id = :id") |
| 1058 | suspend fun deleteConversation(id: Long) | 1061 | suspend fun deleteConversation(id: Long) |
| 1059 | } | 1062 | } |