fix: message ordering and duplicate messages
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user