feat: persistent chat message storage + Android pull-to-load history
Backend:
- Add ChatMessage model + Alembic migration 024
- Add on_message callback to AgentRuntime for persisting messages during SSE streaming
- Plumb session_id from ChatRequest to AgentContext in all 4 chat endpoints
- Add GET /agent-chat/{id}/sessions and /sessions/{sid}/messages with cursor pagination
Android:
- Add DTOs/ApiService/MessageDao for server-side chat history
- ChatRepository: fetchOlderMessages (API + Room cache), offline fallback
- ChatViewModel: loadMoreHistory with isLoadingMore/hasMoreMessages state
- ChatScreen: scroll-to-top detection + top loading indicator
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,4 +23,10 @@ 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")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -97,4 +97,19 @@ interface ApiService {
|
||||
// ─── Feedback ───
|
||||
@POST("api/v1/feedback")
|
||||
suspend fun submitFeedback(@Body request: FeedbackRequest): FeedbackResponse
|
||||
|
||||
// ─── Chat History ───
|
||||
@GET("api/v1/agent-chat/{agentId}/sessions/{sessionId}/messages")
|
||||
suspend fun getSessionMessages(
|
||||
@Path("agentId") agentId: String,
|
||||
@Path("sessionId") sessionId: String,
|
||||
@Query("before_id") beforeId: String? = null,
|
||||
@Query("limit") limit: Int = 50
|
||||
): MessageHistoryResponse
|
||||
|
||||
@GET("api/v1/agent-chat/{agentId}/sessions")
|
||||
suspend fun getAgentSessions(
|
||||
@Path("agentId") agentId: String,
|
||||
@Query("limit") limit: Int = 50
|
||||
): SessionListResponse
|
||||
}
|
||||
|
||||
@@ -275,6 +275,45 @@ data class RegisterResponse(
|
||||
val message: String? = null
|
||||
)
|
||||
|
||||
// ─────────── Feedback ───────────
|
||||
|
||||
|
||||
// ─────────── Chat History ───────────
|
||||
|
||||
data class MessageItemDto(
|
||||
val id: String,
|
||||
@SerializedName("session_id") val sessionId: String,
|
||||
@SerializedName("agent_id") val agentId: String? = null,
|
||||
@SerializedName("user_id") val userId: String? = null,
|
||||
val role: String,
|
||||
val content: String? = null,
|
||||
@SerializedName("tool_name") val toolName: String? = null,
|
||||
@SerializedName("tool_input") val toolInput: String? = null,
|
||||
@SerializedName("tool_output") val toolOutput: String? = null,
|
||||
val iteration: Int = 0,
|
||||
@SerializedName("created_at") val createdAt: String? = null
|
||||
)
|
||||
|
||||
data class MessageHistoryResponse(
|
||||
val messages: List<MessageItemDto>,
|
||||
@SerializedName("has_more") val hasMore: Boolean,
|
||||
val total: Int
|
||||
)
|
||||
|
||||
data class SessionItemDto(
|
||||
@SerializedName("session_id") val sessionId: String,
|
||||
val title: String? = null,
|
||||
@SerializedName("last_message") val lastMessage: String? = null,
|
||||
@SerializedName("message_count") val messageCount: Int = 0,
|
||||
@SerializedName("created_at") val createdAt: String? = null,
|
||||
@SerializedName("updated_at") val updatedAt: String? = null
|
||||
)
|
||||
|
||||
data class SessionListResponse(
|
||||
val sessions: List<SessionItemDto>
|
||||
)
|
||||
|
||||
|
||||
// ─────────── Feedback ───────────
|
||||
|
||||
data class FeedbackRequest(
|
||||
|
||||
@@ -8,6 +8,10 @@ import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.remote.SseClient
|
||||
import com.tiangong.aiagent.data.remote.dto.ChatRequest
|
||||
import com.tiangong.aiagent.data.remote.dto.ChatResponse
|
||||
import com.tiangong.aiagent.data.remote.dto.MessageHistoryResponse
|
||||
import com.tiangong.aiagent.data.remote.dto.MessageItemDto
|
||||
import com.tiangong.aiagent.data.remote.dto.SessionListResponse
|
||||
import com.tiangong.aiagent.data.remote.dto.SessionItemDto
|
||||
import com.tiangong.aiagent.data.remote.dto.TtsRequest
|
||||
import com.tiangong.aiagent.domain.model.Conversation
|
||||
import com.tiangong.aiagent.domain.model.Message
|
||||
@@ -257,4 +261,136 @@ class ChatRepository @Inject constructor(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Chat History (v1.3.0): Server-side pagination + Room cache ───
|
||||
|
||||
/** Fetch older messages from the server for cursor-based pagination. */
|
||||
suspend fun fetchOlderMessages(
|
||||
agentId: String,
|
||||
sessionId: String,
|
||||
beforeId: String? = null,
|
||||
limit: Int = 50
|
||||
): Result<Pair<List<Message>, Boolean>> {
|
||||
return try {
|
||||
val response: MessageHistoryResponse = apiService.getSessionMessages(
|
||||
agentId = agentId,
|
||||
sessionId = sessionId,
|
||||
beforeId = beforeId,
|
||||
limit = limit
|
||||
)
|
||||
// Cache fetched messages to local Room
|
||||
cacheMessagesFromApi(response.messages, sessionId, agentId)
|
||||
val messages = response.messages.map { it.toDomainMessage() }
|
||||
Result.success(Pair(messages, response.hasMore))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch agent's session list from server. */
|
||||
suspend fun fetchAgentSessions(agentId: String, limit: Int = 50): Result<List<SessionItemDto>> {
|
||||
return try {
|
||||
val response: SessionListResponse = apiService.getAgentSessions(agentId, limit)
|
||||
Result.success(response.sessions)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Get older messages from local Room (offline fallback). */
|
||||
suspend fun getOlderMessagesFromRoom(
|
||||
conversationId: String,
|
||||
beforeTimestamp: Long,
|
||||
limit: Int = 50
|
||||
): List<Message> {
|
||||
val entities = database.messageDao().getMessagesBeforeTimestamp(
|
||||
conversationId = conversationId,
|
||||
beforeTimestamp = beforeTimestamp,
|
||||
limit = limit
|
||||
)
|
||||
return entities.map { it.toDomainMessage() }
|
||||
}
|
||||
|
||||
/** Check if there are more messages available on the server for a session. */
|
||||
suspend fun hasMoreServerMessages(agentId: String, sessionId: String): Boolean {
|
||||
val localCount = database.messageDao().getMessageCount(sessionId)
|
||||
return try {
|
||||
val response = apiService.getSessionMessages(
|
||||
agentId = agentId, sessionId = sessionId, beforeId = null, limit = 1
|
||||
)
|
||||
response.total > localCount
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
private suspend fun cacheMessagesFromApi(dtos: List<MessageItemDto>, sessionId: String, agentId: String?) {
|
||||
val dao = database.messageDao()
|
||||
for (dto in dtos) {
|
||||
dao.insert(
|
||||
MessageEntity(
|
||||
id = dto.id,
|
||||
conversationId = sessionId,
|
||||
agentId = dto.agentId ?: agentId,
|
||||
role = dto.role,
|
||||
content = dto.content ?: "",
|
||||
toolName = dto.toolName,
|
||||
toolInput = dto.toolInput,
|
||||
toolOutput = dto.toolOutput,
|
||||
tokenUsageJson = null,
|
||||
createdAt = dto.createdAt?.let { parseIso8601ToEpoch(it) } ?: System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessageItemDto.toDomainMessage(): Message {
|
||||
return Message(
|
||||
id = id,
|
||||
conversationId = sessionId,
|
||||
agentId = agentId,
|
||||
role = try {
|
||||
Message.Role.valueOf(role.uppercase())
|
||||
} catch (e: Exception) {
|
||||
Message.Role.SYSTEM
|
||||
},
|
||||
content = content ?: "",
|
||||
toolName = toolName,
|
||||
toolInput = toolInput,
|
||||
toolOutput = toolOutput,
|
||||
createdAt = createdAt?.let { parseIso8601ToEpoch(it) } ?: System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
private fun MessageEntity.toDomainMessage(): Message {
|
||||
return Message(
|
||||
id = id,
|
||||
conversationId = conversationId,
|
||||
agentId = agentId,
|
||||
role = try {
|
||||
Message.Role.valueOf(role.uppercase())
|
||||
} catch (e: Exception) {
|
||||
Message.Role.SYSTEM
|
||||
},
|
||||
content = content,
|
||||
toolName = toolName,
|
||||
toolInput = toolInput,
|
||||
toolOutput = toolOutput,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parseIso8601ToEpoch(isoString: String): Long {
|
||||
return try {
|
||||
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US)
|
||||
sdf.timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
sdf.parse(isoString.substringBefore('.'))?.time ?: System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +74,27 @@ fun ChatScreen(
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
// Auto-scroll when new messages arrive
|
||||
// Auto-scroll when new messages arrive (only when not loading more history)
|
||||
LaunchedEffect(uiState.messages.size) {
|
||||
if (uiState.messages.isNotEmpty()) {
|
||||
if (uiState.messages.isNotEmpty() && !uiState.isLoadingMore) {
|
||||
listState.animateScrollToItem(uiState.messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll-to-top detection for loading more history
|
||||
val isAtTop by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex == 0 &&
|
||||
listState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isAtTop) {
|
||||
if (isAtTop && uiState.hasMoreMessages && !uiState.isLoadingMore) {
|
||||
viewModel.loadMoreHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fill input when editing a message
|
||||
LaunchedEffect(uiState.editingMessageId) {
|
||||
uiState.editingMessageContent?.let { inputText = it }
|
||||
@@ -452,6 +466,28 @@ fun ChatScreen(
|
||||
modifier = Modifier.fillMaxSize().weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
// Loading indicator at top (pull-to-load more history)
|
||||
if (uiState.isLoadingMore) {
|
||||
item(key = "loading_more") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "加载更多...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skeleton loading on first load
|
||||
if (!firstLoaded && uiState.messages.isEmpty() && !uiState.isStreaming) {
|
||||
item { SkeletonChat() }
|
||||
|
||||
@@ -69,7 +69,11 @@ data class ChatUiState(
|
||||
val pendingQueueCount: Int = 0,
|
||||
|
||||
// Think trace entries (v1.1.0)
|
||||
val thinkTraces: List<ThinkTraceEntry> = emptyList()
|
||||
val thinkTraces: List<ThinkTraceEntry> = emptyList(),
|
||||
|
||||
// Pull-to-load history (v1.3.0)
|
||||
val isLoadingMore: Boolean = false,
|
||||
val hasMoreMessages: Boolean = true
|
||||
)
|
||||
|
||||
data class ThinkTraceEntry(
|
||||
@@ -236,13 +240,34 @@ class ChatViewModel @Inject constructor(
|
||||
historyFlowJob?.cancel()
|
||||
historyFlowJob = viewModelScope.launch {
|
||||
val conversations = chatRepository.getConversationsByAgent(agentId)
|
||||
val latestConversation = conversations.firstOrNull() ?: return@launch
|
||||
chatRepository.getMessages(latestConversation.sessionId).collect { messages ->
|
||||
if (messages.isNotEmpty() && _uiState.value.currentAgent?.id == agentId) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = messages.map { it.toUiMessage() },
|
||||
sessionId = latestConversation.sessionId
|
||||
val latestConversation = conversations.firstOrNull()
|
||||
if (latestConversation != null) {
|
||||
chatRepository.getMessages(latestConversation.sessionId).collect { messages ->
|
||||
if (messages.isNotEmpty() && _uiState.value.currentAgent?.id == agentId) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = messages.map { it.toUiMessage() },
|
||||
sessionId = latestConversation.sessionId
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No local conversations — try fetching from server
|
||||
val result = chatRepository.fetchAgentSessions(agentId, limit = 1)
|
||||
if (result.isSuccess) {
|
||||
val sessions = result.getOrThrow()
|
||||
val latestSession = sessions.firstOrNull() ?: return@launch
|
||||
val msgResult = chatRepository.fetchOlderMessages(
|
||||
agentId = agentId, sessionId = latestSession.sessionId, limit = 50
|
||||
)
|
||||
if (msgResult.isSuccess) {
|
||||
val (msgs, hasMore) = msgResult.getOrThrow()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = msgs.map { it.toUiMessage() },
|
||||
sessionId = latestSession.sessionId,
|
||||
hasMoreMessages = hasMore
|
||||
)
|
||||
tokenDataStore.saveLastSessionId(latestSession.sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,16 +296,106 @@ class ChatViewModel @Inject constructor(
|
||||
sseJob?.cancel()
|
||||
viewModelScope.launch {
|
||||
tokenDataStore.saveLastSessionId(sessionId)
|
||||
chatRepository.getMessages(sessionId).collect { messages ->
|
||||
if (messages.isNotEmpty()) {
|
||||
// First check Room
|
||||
val localMsgs = chatRepository.getMessages(sessionId).first()
|
||||
if (localMsgs.isNotEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = localMsgs.map { it.toUiMessage() },
|
||||
sessionId = sessionId,
|
||||
streamingContent = "",
|
||||
isLoading = false,
|
||||
isStreaming = false,
|
||||
error = null,
|
||||
reconnectionState = ReconnectionState.Idle
|
||||
)
|
||||
// Continue observing local changes
|
||||
historyFlowJob?.cancel()
|
||||
historyFlowJob = viewModelScope.launch {
|
||||
chatRepository.getMessages(sessionId).collect { messages ->
|
||||
if (messages.isNotEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = messages.map { it.toUiMessage() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Try server
|
||||
val agentId = _uiState.value.currentAgent?.id ?: return@launch
|
||||
val result = chatRepository.fetchOlderMessages(
|
||||
agentId = agentId, sessionId = sessionId, limit = 50
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
val (msgs, hasMore) = result.getOrThrow()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = messages.map { it.toUiMessage() },
|
||||
messages = msgs.map { it.toUiMessage() },
|
||||
sessionId = sessionId,
|
||||
streamingContent = "",
|
||||
isLoading = false,
|
||||
isStreaming = false,
|
||||
error = null,
|
||||
reconnectionState = ReconnectionState.Idle
|
||||
reconnectionState = ReconnectionState.Idle,
|
||||
hasMoreMessages = hasMore
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMoreHistory() {
|
||||
val state = _uiState.value
|
||||
if (state.isLoadingMore || !state.hasMoreMessages) return
|
||||
val agentId = state.currentAgent?.id ?: return
|
||||
val sessionId = state.sessionId ?: return
|
||||
val oldestMsg = state.messages.firstOrNull() ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoadingMore = true)
|
||||
|
||||
// Capture current scroll anchor (first visible item id + offset)
|
||||
val anchorId = oldestMsg.id
|
||||
|
||||
val result = chatRepository.fetchOlderMessages(
|
||||
agentId = agentId,
|
||||
sessionId = sessionId,
|
||||
beforeId = anchorId,
|
||||
limit = 50
|
||||
)
|
||||
|
||||
if (result.isSuccess) {
|
||||
val (olderMessages, hasMore) = result.getOrThrow()
|
||||
val current = _uiState.value.messages.toMutableList()
|
||||
// Prepend older messages (avoid duplicates by id)
|
||||
val existingIds = current.map { it.id }.toSet()
|
||||
val newMsgs = olderMessages.filter { it.id !in existingIds }.map { it.toUiMessage() }
|
||||
current.addAll(0, newMsgs)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = current,
|
||||
hasMoreMessages = hasMore,
|
||||
isLoadingMore = false
|
||||
)
|
||||
} else {
|
||||
// API failed — try Room fallback
|
||||
val oldestTimestamp = oldestMsg.createdAt
|
||||
val roomMsgs = chatRepository.getOlderMessagesFromRoom(
|
||||
conversationId = sessionId,
|
||||
beforeTimestamp = oldestTimestamp,
|
||||
limit = 50
|
||||
)
|
||||
if (roomMsgs.isNotEmpty()) {
|
||||
val current = _uiState.value.messages.toMutableList()
|
||||
val existingIds = current.map { it.id }.toSet()
|
||||
val newMsgs = roomMsgs.filter { it.id !in existingIds }.map { it.toUiMessage() }
|
||||
current.addAll(0, newMsgs)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = current,
|
||||
hasMoreMessages = roomMsgs.size >= 50,
|
||||
isLoadingMore = false
|
||||
)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
hasMoreMessages = false,
|
||||
isLoadingMore = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user