fix: message ordering and duplicate messages
Some checks failed
CI/CD Pipeline / Backend — Lint & Test (push) Has been cancelled
CI/CD Pipeline / Frontend — Lint & Build (push) Has been cancelled
CI/CD Pipeline / Docker — Build Check (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 22:09:03 +08:00
parent cffe4a52d6
commit e33aa6e0b7
6 changed files with 71 additions and 15 deletions

View File

@@ -12,7 +12,7 @@ interface MessageDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(messages: List<MessageEntity>)
@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<List<MessageEntity>>
@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<MessageEntity>
@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
}

View File

@@ -329,6 +329,10 @@ class ChatRepository @Inject constructor(
private suspend fun cacheMessagesFromApi(dtos: List<MessageItemDto>, 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,

View File

@@ -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()
}
}

View File

@@ -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<UiMessage> { 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<UiMessage> { 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
)

View File

@@ -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))
}
}
}