feat: persistent chat message storage + Android pull-to-load history
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:
- 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:
2026-06-30 00:07:26 +08:00
parent 569e3ab7df
commit a06082480a
12 changed files with 705 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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