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:
parent
f3d8154052
commit
532cb8c27b
7 changed files with 41 additions and 9 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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() },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue