From e33aa6e0b738f35283332b41c5e55a3e923e50ce Mon Sep 17 00:00:00 2001 From: renjianbo <263303411@qq.com> Date: Tue, 30 Jun 2026 22:09:03 +0800 Subject: [PATCH] fix: message ordering and duplicate messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Composite ORDER BY (created_at DESC, iteration ASC, id ASC) to fix non-deterministic ordering when multiple messages share same second - Composite key cursor pagination to avoid missing messages at boundary Android: - Fix message duplication: guard loadMoreHistory from firing before initial scroll completes (isAtTop race → fetchOlderMessages → cacheMessagesFromApi with different IDs → Room duplicates) - Add distinctBy(id) at all message assignment points in ViewModel - cacheMessagesFromApi: skip insert if same ID or same content exists - Add relative timestamp display on message bubbles Co-Authored-By: Claude Opus 4.6 --- .../tiangong/aiagent/data/local/MessageDao.kt | 10 +++++-- .../aiagent/data/repository/ChatRepository.kt | 4 +++ .../tiangong/aiagent/ui/chat/ChatScreen.kt | 6 +++- .../tiangong/aiagent/ui/chat/ChatViewModel.kt | 22 +++++++++------ .../ui/chat/components/MessageBubble.kt | 28 +++++++++++++++++++ backend/app/api/agent_chat.py | 16 ++++++++--- 6 files changed, 71 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/local/MessageDao.kt b/android/app/src/main/java/com/tiangong/aiagent/data/local/MessageDao.kt index 2eab3ce..131191a 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/local/MessageDao.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/local/MessageDao.kt @@ -12,7 +12,7 @@ interface MessageDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(messages: List) - @Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY createdAt ASC") + @Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY createdAt ASC, id ASC") fun getMessagesByConversation(conversationId: String): Flow> @Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY createdAt ASC LIMIT 1") @@ -24,9 +24,15 @@ interface MessageDao { @Query("DELETE FROM messages") suspend fun deleteAll() - @Query("SELECT * FROM messages WHERE conversationId = :conversationId AND createdAt < :beforeTimestamp ORDER BY createdAt DESC LIMIT :limit") + @Query("SELECT * FROM messages WHERE conversationId = :conversationId AND createdAt < :beforeTimestamp ORDER BY createdAt DESC, id DESC LIMIT :limit") suspend fun getMessagesBeforeTimestamp(conversationId: String, beforeTimestamp: Long, limit: Int): List @Query("SELECT COUNT(*) FROM messages WHERE conversationId = :conversationId") suspend fun getMessageCount(conversationId: String): Int + + @Query("SELECT * FROM messages WHERE id = :id LIMIT 1") + suspend fun getMessageById(id: String): MessageEntity? + + @Query("SELECT COUNT(*) FROM messages WHERE conversationId = :conversationId AND role = :role AND content = :content") + suspend fun countByConvRoleContent(conversationId: String, role: String, content: String): Int } diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt index 6691b45..d00df80 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt @@ -329,6 +329,10 @@ class ChatRepository @Inject constructor( private suspend fun cacheMessagesFromApi(dtos: List, sessionId: String, agentId: String?) { val dao = database.messageDao() for (dto in dtos) { + // Skip if already cached (same server ID) + if (dao.getMessageById(dto.id) != null) continue + // Skip if same content already saved locally (prevents local-ID vs server-ID duplicates) + if (dao.countByConvRoleContent(sessionId, dto.role, dto.content ?: "") > 0) continue dao.insert( MessageEntity( id = dto.id, diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt index 97b9fc4..f19b163 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt @@ -74,10 +74,14 @@ fun ChatScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + // Guard: prevent loadMoreHistory from firing before initial auto-scroll completes + var initialScrollDone by remember { mutableStateOf(false) } + // Auto-scroll when new messages arrive (only when not loading more history) LaunchedEffect(uiState.messages.size) { if (uiState.messages.isNotEmpty() && !uiState.isLoadingMore) { listState.animateScrollToItem(uiState.messages.size - 1) + initialScrollDone = true } } @@ -90,7 +94,7 @@ fun ChatScreen( } LaunchedEffect(isAtTop) { - if (isAtTop && uiState.hasMoreMessages && !uiState.isLoadingMore) { + if (isAtTop && uiState.hasMoreMessages && !uiState.isLoadingMore && initialScrollDone) { viewModel.loadMoreHistory() } } diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt index 1163f67..3f4eeab 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt @@ -183,7 +183,7 @@ class ChatViewModel @Inject constructor( chatRepository.getMessages(sessionId).collect { messages -> if (messages.isNotEmpty() && _uiState.value.currentAgent?.id == currentAgentId) { _uiState.value = _uiState.value.copy( - messages = messages.map { it.toUiMessage() }, + messages = messages.map { it.toUiMessage() }.distinctBy { it.id }.distinctBy { it.id }, sessionId = sessionId ) } @@ -245,7 +245,7 @@ class ChatViewModel @Inject constructor( chatRepository.getMessages(latestConversation.sessionId).collect { messages -> if (messages.isNotEmpty() && _uiState.value.currentAgent?.id == agentId) { _uiState.value = _uiState.value.copy( - messages = messages.map { it.toUiMessage() }, + messages = messages.map { it.toUiMessage() }.distinctBy { it.id }, sessionId = latestConversation.sessionId ) } @@ -262,7 +262,7 @@ class ChatViewModel @Inject constructor( if (msgResult.isSuccess) { val (msgs, hasMore) = msgResult.getOrThrow() _uiState.value = _uiState.value.copy( - messages = msgs.map { it.toUiMessage() }, + messages = msgs.map { it.toUiMessage() }.distinctBy { it.id }, sessionId = latestSession.sessionId, hasMoreMessages = hasMore ) @@ -300,7 +300,7 @@ class ChatViewModel @Inject constructor( val localMsgs = chatRepository.getMessages(sessionId).first() if (localMsgs.isNotEmpty()) { _uiState.value = _uiState.value.copy( - messages = localMsgs.map { it.toUiMessage() }, + messages = localMsgs.map { it.toUiMessage() }.distinctBy { it.id }, sessionId = sessionId, streamingContent = "", isLoading = false, @@ -314,7 +314,7 @@ class ChatViewModel @Inject constructor( chatRepository.getMessages(sessionId).collect { messages -> if (messages.isNotEmpty()) { _uiState.value = _uiState.value.copy( - messages = messages.map { it.toUiMessage() } + messages = messages.map { it.toUiMessage() }.distinctBy { it.id } ) } } @@ -328,7 +328,7 @@ class ChatViewModel @Inject constructor( if (result.isSuccess) { val (msgs, hasMore) = result.getOrThrow() _uiState.value = _uiState.value.copy( - messages = msgs.map { it.toUiMessage() }, + messages = msgs.map { it.toUiMessage() }.distinctBy { it.id }, sessionId = sessionId, streamingContent = "", isLoading = false, @@ -369,8 +369,11 @@ class ChatViewModel @Inject constructor( val existingIds = current.map { it.id }.toSet() val newMsgs = olderMessages.filter { it.id !in existingIds }.map { it.toUiMessage() } current.addAll(0, newMsgs) + // Sort by createdAt then id for deterministic order (same-second messages) + current.sortWith(compareBy { it.createdAt }.thenBy { it.id }) + val deduped = current.distinctBy { it.id } _uiState.value = _uiState.value.copy( - messages = current, + messages = deduped, hasMoreMessages = hasMore, isLoadingMore = false ) @@ -387,8 +390,11 @@ class ChatViewModel @Inject constructor( val existingIds = current.map { it.id }.toSet() val newMsgs = roomMsgs.filter { it.id !in existingIds }.map { it.toUiMessage() } current.addAll(0, newMsgs) + // Sort by createdAt then id for deterministic order + current.sortWith(compareBy { it.createdAt }.thenBy { it.id }) + val deduped = current.distinctBy { it.id } _uiState.value = _uiState.value.copy( - messages = current, + messages = deduped, hasMoreMessages = roomMsgs.size >= 50, isLoadingMore = false ) diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt index 7bd718b..f6c3ed4 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt @@ -12,6 +12,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.tiangong.aiagent.domain.model.Message import com.tiangong.aiagent.util.MarkdownRenderer +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @Composable fun MessageBubble(message: Message) { @@ -79,5 +82,30 @@ fun MessageBubble(message: Message) { ) } } + + // Timestamp + if (message.createdAt > 0) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = formatTimestamp(message.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } +} + +private fun formatTimestamp(epochMillis: Long): String { + val now = System.currentTimeMillis() + val diff = now - epochMillis + return when { + diff < 60_000 -> "刚刚" + diff < 3600_000 -> "${diff / 60_000} 分钟前" + diff < 86400_000 -> "${diff / 3600_000} 小时前" + else -> { + val fmt = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) + fmt.format(Date(epochMillis)) + } } } diff --git a/backend/app/api/agent_chat.py b/backend/app/api/agent_chat.py index 19e5363..189537f 100644 --- a/backend/app/api/agent_chat.py +++ b/backend/app/api/agent_chat.py @@ -604,7 +604,7 @@ async def list_agent_sessions( db: Session = Depends(get_db), ): """获取 Agent 的会话列表,按最近活跃时间排序。""" - from sqlalchemy import func as sa_func, desc + from sqlalchemy import func as sa_func, desc, or_ # 验证 agent 存在或有权限 agent = db.query(Agent).filter(Agent.id == agent_id).first() @@ -666,6 +666,8 @@ async def get_session_messages( db: Session = Depends(get_db), ): """获取会话的消息历史(分页),从旧到新排序。""" + from sqlalchemy import or_ + # limit 限制 limit = min(max(limit, 1), 200) @@ -674,16 +676,22 @@ async def get_session_messages( ChatMessage.session_id == session_id, ) - # 游标分页:before_id 之前的老消息 + # 游标分页:before_id 之前的老消息(使用复合键避免 created_at 相同导致遗漏) if before_id: cursor_msg = db.query(ChatMessage).filter(ChatMessage.id == before_id).first() if cursor_msg and cursor_msg.created_at: - base_q = base_q.filter(ChatMessage.created_at < cursor_msg.created_at) + base_q = base_q.filter( + or_( + ChatMessage.created_at < cursor_msg.created_at, + (ChatMessage.created_at == cursor_msg.created_at) & (ChatMessage.id < cursor_msg.id), + ) + ) # 取 N+1 条,判断 has_more(按时间降序取最新 N 条,再反转) + # 二级排序 iteration + id 避免同一秒内多条消息顺序不确定 batch = ( base_q - .order_by(ChatMessage.created_at.desc()) + .order_by(ChatMessage.created_at.desc(), ChatMessage.iteration.asc(), ChatMessage.id.asc()) .limit(limit + 1) .all() )