persist reasoning + sources, render reasoning above content

Fix two bugs:
1. ReasoningBlock rendered below content — moved above, matching
   web frontend (reasoning first, then content, sources at bottom)
2. Reasoning and sources lost after closing chat — now persisted
   end-to-end: sent to server via SaveMessage, loaded back via
   StoredMessage, cached in Room via MessageEntity + SearchSource
   TypeConverter. Syncs correctly between web and app.

Room schema change triggers destructive migration (cache rebuild).
This commit is contained in:
Bruno Deanoz 2026-06-21 23:39:07 +02:00
parent f3d8154052
commit 532cb8c27b
7 changed files with 41 additions and 9 deletions

View file

@ -221,6 +221,10 @@ fun MessageRow(message: Message) {
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Box(Modifier.weight(1f).padding(top = 3.dp)) { Box(Modifier.weight(1f).padding(top = 3.dp)) {
Column { Column {
if (message.reasoning.isNotBlank()) {
ReasoningBlock(message.reasoning, streaming = message.streaming)
Spacer(Modifier.height(8.dp))
}
if (message.tools.isNotEmpty()) { if (message.tools.isNotEmpty()) {
ToolEventsBlock(message.tools, streaming = message.streaming) ToolEventsBlock(message.tools, streaming = message.streaming)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -231,10 +235,6 @@ fun MessageRow(message: Message) {
} }
if (message.thinking) TypingDots() if (message.thinking) TypingDots()
else MarkdownText(text = message.content, streaming = message.streaming) else MarkdownText(text = message.content, streaming = message.streaming)
if (message.reasoning.isNotBlank()) {
Spacer(Modifier.height(8.dp))
ReasoningBlock(message.reasoning, streaming = message.streaming)
}
if (message.sources.isNotEmpty()) { if (message.sources.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
SourcesBlock(message.sources, query = message.query) SourcesBlock(message.sources, query = message.query)

View file

@ -224,6 +224,8 @@ fun ChatScreen(
content = m.content, content = m.content,
wireId = m.id, wireId = m.id,
attachments = m.attachments, attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
), ),
) )
} }
@ -233,7 +235,6 @@ fun ChatScreen(
if (stored.isNotEmpty()) { if (stored.isNotEmpty()) {
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) } val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
chat.messageRepo.cacheMessages(summary.id, entities) chat.messageRepo.cacheMessages(summary.id, entities)
// Only rebuild message list if content actually changed
if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) { if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) {
messages.clear() messages.clear()
stored.forEach { m -> stored.forEach { m ->
@ -244,6 +245,8 @@ fun ChatScreen(
content = m.content, content = m.content,
wireId = m.id, wireId = m.id,
attachments = m.attachments, attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
), ),
) )
} }
@ -303,7 +306,10 @@ fun ChatScreen(
) )
} }
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
val finalContent = if (idx >= 0) messages[idx].content else "" val finalMsg = if (idx >= 0) messages[idx] else null
val finalContent = finalMsg?.content ?: ""
val finalReasoning = finalMsg?.reasoning?.takeIf { it.isNotBlank() }
val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() }
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false) if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId
@ -317,11 +323,15 @@ fun ChatScreen(
role = "user", content = trimmed, role = "user", content = trimmed,
attachments = uploadedAtts.takeIf { it.isNotEmpty() }, attachments = uploadedAtts.takeIf { it.isNotEmpty() },
), ),
SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model), SaveMessage(
id = assistantWireId, parentId = userMsg.wireId,
role = "assistant", content = finalContent, model = cfg.model,
reasoning = finalReasoning,
sources = finalSources,
),
), ),
) )
if (saved) { if (saved) {
// Cache new messages in Room for offline-first
val msgCount = messages.size val msgCount = messages.size
chat.messageRepo.cacheMessages(convId, listOf( chat.messageRepo.cacheMessages(convId, listOf(
MessageEntity( MessageEntity(
@ -333,6 +343,8 @@ fun ChatScreen(
id = assistantWireId, conversationId = convId, id = assistantWireId, conversationId = convId,
role = "assistant", content = finalContent, role = "assistant", content = finalContent,
attachments = emptyList(), sortOrder = msgCount - 1, attachments = emptyList(), sortOrder = msgCount - 1,
reasoning = finalReasoning,
sources = finalSources ?: emptyList(),
), ),
)) ))
if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId) if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)

View file

@ -79,7 +79,7 @@ fun ReasoningBlock(reasoning: String, streaming: Boolean = false) {
Icon(Icons.Rounded.Psychology, null, tint = accent.primary, modifier = Modifier.size(16.dp)) Icon(Icons.Rounded.Psychology, null, tint = accent.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
"Gedankengang", stringResource(R.string.stream_reasoning),
color = cs.onSurfaceVariant, color = cs.onSurfaceVariant,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,

View file

@ -2,6 +2,7 @@ package dev.kaizen.app.db
import androidx.room.TypeConverter import androidx.room.TypeConverter
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -15,4 +16,12 @@ class Converters {
@TypeConverter @TypeConverter
fun jsonToAttachments(value: String): List<Attachment> = fun jsonToAttachments(value: String): List<Attachment> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() } try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
@TypeConverter
fun sourcesToJson(sources: List<SearchSource>): String =
json.encodeToString(sources)
@TypeConverter
fun jsonToSources(value: String): List<SearchSource> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
} }

View file

@ -5,6 +5,7 @@ import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
@Entity(tableName = "conversations") @Entity(tableName = "conversations")
data class ConversationEntity( data class ConversationEntity(
@ -33,4 +34,6 @@ data class MessageEntity(
val content: String, val content: String,
val attachments: List<Attachment>, val attachments: List<Attachment>,
val sortOrder: Int, val sortOrder: Int,
val reasoning: String? = null,
val sources: List<SearchSource> = emptyList(),
) )

View file

@ -27,6 +27,8 @@ fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEnti
content = content, content = content,
attachments = attachments, attachments = attachments,
sortOrder = sortOrder, sortOrder = sortOrder,
reasoning = reasoning,
sources = sources ?: emptyList(),
) )
fun MessageEntity.toStoredMessage() = StoredMessage( fun MessageEntity.toStoredMessage() = StoredMessage(
@ -34,4 +36,6 @@ fun MessageEntity.toStoredMessage() = StoredMessage(
role = role, role = role,
content = content, content = content,
attachments = attachments, attachments = attachments,
reasoning = reasoning,
sources = sources.takeIf { it.isNotEmpty() },
) )

View file

@ -79,6 +79,8 @@ data class StoredMessage(
val role: String, val role: String,
val content: String = "", val content: String = "",
val attachments: List<Attachment> = emptyList(), val attachments: List<Attachment> = emptyList(),
val reasoning: String? = null,
val sources: List<SearchSource>? = null,
) )
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList()) @Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
@ -93,6 +95,8 @@ data class SaveMessage(
val kind: String = "chat", val kind: String = "chat",
val model: String? = null, val model: String? = null,
val attachments: List<Attachment>? = null, val attachments: List<Attachment>? = null,
val reasoning: String? = null,
val sources: List<SearchSource>? = null,
) )
@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>) @Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>)