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))
Box(Modifier.weight(1f).padding(top = 3.dp)) {
Column {
if (message.reasoning.isNotBlank()) {
ReasoningBlock(message.reasoning, streaming = message.streaming)
Spacer(Modifier.height(8.dp))
}
if (message.tools.isNotEmpty()) {
ToolEventsBlock(message.tools, streaming = message.streaming)
Spacer(Modifier.height(8.dp))
@ -231,10 +235,6 @@ fun MessageRow(message: Message) {
}
if (message.thinking) TypingDots()
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()) {
Spacer(Modifier.height(8.dp))
SourcesBlock(message.sources, query = message.query)

View file

@ -224,6 +224,8 @@ fun ChatScreen(
content = m.content,
wireId = m.id,
attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
),
)
}
@ -233,7 +235,6 @@ fun ChatScreen(
if (stored.isNotEmpty()) {
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
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 }) {
messages.clear()
stored.forEach { m ->
@ -244,6 +245,8 @@ fun ChatScreen(
content = m.content,
wireId = m.id,
attachments = m.attachments,
reasoning = m.reasoning ?: "",
sources = m.sources ?: emptyList(),
),
)
}
@ -303,7 +306,10 @@ fun ChatScreen(
)
}
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)
val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId
@ -317,11 +323,15 @@ fun ChatScreen(
role = "user", content = trimmed,
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) {
// Cache new messages in Room for offline-first
val msgCount = messages.size
chat.messageRepo.cacheMessages(convId, listOf(
MessageEntity(
@ -333,6 +343,8 @@ fun ChatScreen(
id = assistantWireId, conversationId = convId,
role = "assistant", content = finalContent,
attachments = emptyList(), sortOrder = msgCount - 1,
reasoning = finalReasoning,
sources = finalSources ?: emptyList(),
),
))
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))
Spacer(Modifier.width(8.dp))
Text(
"Gedankengang",
stringResource(R.string.stream_reasoning),
color = cs.onSurfaceVariant,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,

View file

@ -2,6 +2,7 @@ package dev.kaizen.app.db
import androidx.room.TypeConverter
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@ -15,4 +16,12 @@ class Converters {
@TypeConverter
fun jsonToAttachments(value: String): List<Attachment> =
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.PrimaryKey
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
@Entity(tableName = "conversations")
data class ConversationEntity(
@ -33,4 +34,6 @@ data class MessageEntity(
val content: String,
val attachments: List<Attachment>,
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,
attachments = attachments,
sortOrder = sortOrder,
reasoning = reasoning,
sources = sources ?: emptyList(),
)
fun MessageEntity.toStoredMessage() = StoredMessage(
@ -34,4 +36,6 @@ fun MessageEntity.toStoredMessage() = StoredMessage(
role = role,
content = content,
attachments = attachments,
reasoning = reasoning,
sources = sources.takeIf { it.isNotEmpty() },
)

View file

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