feat(chat): wire sidebar to real conversations + persist history

Sidebar now lists the user's conversations from GET /api/v1/conversations
(date-grouped, pinned first, active highlighted) instead of mock data. Sending
creates a server conversation on the first message, persists each exchange via
POST .../messages, and auto-titles new conversations. Tapping a chat loads its
stored messages; new-chat/logout reset the active conversation. Linear model
(no branching); locked chats are skipped (no in-app unlock yet).
This commit is contained in:
Bruno Deanoz 2026-06-19 07:58:07 +02:00
parent d2f269f33a
commit ea5e625f0f
4 changed files with 273 additions and 55 deletions

View file

@ -18,6 +18,9 @@ data class Message(
val content: String, val content: String,
val streaming: Boolean = false, val streaming: Boolean = false,
val thinking: Boolean = false, val thinking: Boolean = false,
// Stable server-side id (UUID) used for persistence + parentId chaining. Generated
// on creation; overwritten with the DB id when loading an existing conversation.
val wireId: String = java.util.UUID.randomUUID().toString(),
) )
// Empty-state suggestion pills (mirrors the web hero) // Empty-state suggestion pills (mirrors the web hero)

View file

@ -51,8 +51,10 @@ import androidx.compose.ui.unit.dp
import dev.kaizen.app.haptics.rememberHaptics import dev.kaizen.app.haptics.rememberHaptics
import dev.kaizen.app.net.ChatHttpException import dev.kaizen.app.net.ChatHttpException
import dev.kaizen.app.net.ChatSpeed import dev.kaizen.app.net.ChatSpeed
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.KaizenApi import dev.kaizen.app.net.KaizenApi
import dev.kaizen.app.net.KaizenModel import dev.kaizen.app.net.KaizenModel
import dev.kaizen.app.net.SaveMessage
import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.net.WireMessage import dev.kaizen.app.net.WireMessage
import dev.kaizen.app.settings.SettingsScreen import dev.kaizen.app.settings.SettingsScreen
@ -93,9 +95,47 @@ fun ChatScreen(
// Model picker: catalog (best-effort fetch) + sheet visibility // Model picker: catalog (best-effort fetch) + sheet visibility
var models by remember { mutableStateOf<List<KaizenModel>>(emptyList()) } var models by remember { mutableStateOf<List<KaizenModel>>(emptyList()) }
var showModelSheet by remember { mutableStateOf(false) } var showModelSheet by remember { mutableStateOf(false) }
// Conversation persistence: the active server conversation + the sidebar list
var conversationId by remember { mutableStateOf<String?>(null) }
var conversations by remember { mutableStateOf<List<ConversationSummary>>(emptyList()) }
LaunchedEffect(session.config?.baseUrl, session.config?.token) { LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect val cfg = session.config ?: return@LaunchedEffect
models = KaizenApi.fetchModels(cfg.baseUrl, cfg.token) models = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)
conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
}
fun refreshConversations() {
val cfg = session.config ?: return
scope.launch { conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) }
}
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */
fun newChat() {
messages.clear()
conversationId = null
}
/** Load a stored conversation into the linear message list. Locked ones are skipped (no in-app unlock yet). */
fun openConversation(summary: ConversationSummary) {
if (summary.locked) return
val cfg = session.config ?: return
scope.launch {
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
messages.clear()
stored.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
),
)
}
conversationId = summary.id
}
} }
fun send(text: String) { fun send(text: String) {
@ -103,11 +143,16 @@ fun ChatScreen(
if (trimmed.isEmpty() || isStreaming) return if (trimmed.isEmpty() || isStreaming) return
val cfg = session.config ?: return val cfg = session.config ?: return
haptics.click() haptics.click()
messages.add(Message(nextId++, Role.User, trimmed)) // parentId chains the linear history: the user msg hangs off the last message.
val userParentId = messages.lastOrNull()?.wireId
val userMsg = Message(nextId++, Role.User, trimmed)
messages.add(userMsg)
input = "" input = ""
val assistantId = nextId++ val assistantId = nextId++
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true)) val assistantWireId = java.util.UUID.randomUUID().toString()
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
isStreaming = true isStreaming = true
val wasNewConversation = conversationId == null
// Wire history = the visible conversation minus the streaming placeholder. // Wire history = the visible conversation minus the streaming placeholder.
val history = messages val history = messages
@ -118,6 +163,11 @@ fun ChatScreen(
haptics.thinkingStart() haptics.thinkingStart()
var sawContent = false var sawContent = false
try { try {
// Ensure a server conversation exists before the exchange is persisted.
val convId = conversationId ?: KaizenApi.createConversation(cfg.baseUrl, cfg.token)?.also {
conversationId = it
}
KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed).collect { visible -> KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed).collect { visible ->
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect if (idx < 0) return@collect
@ -131,7 +181,21 @@ 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 ""
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false) if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
// Persist the exchange (best-effort); auto-title + sidebar refresh on a new conversation.
if (convId != null && finalContent.isNotEmpty()) {
val saved = KaizenApi.saveMessages(
cfg.baseUrl, cfg.token, convId,
listOf(
SaveMessage(id = userMsg.wireId, parentId = userParentId, role = "user", content = trimmed),
SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model),
),
)
if (saved && wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
if (saved) refreshConversations()
}
} catch (e: Exception) { } catch (e: Exception) {
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
if (idx >= 0) messages[idx] = messages[idx].copy( if (idx >= 0) messages[idx] = messages[idx].copy(
@ -168,10 +232,17 @@ fun ChatScreen(
drawerContent = { drawerContent = {
KaizenSidebar( KaizenSidebar(
userName = userName, userName = userName,
conversations = conversations,
currentId = conversationId,
onClose = { scope.launch { drawerState.close() } }, onClose = { scope.launch { drawerState.close() } },
onNewChat = { onNewChat = {
haptics.tick() haptics.tick()
messages.clear() newChat()
scope.launch { drawerState.close() }
},
onSelectConversation = { summary ->
haptics.tick()
openConversation(summary)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
}, },
onOpenSettings = { onOpenSettings = {
@ -181,7 +252,7 @@ fun ChatScreen(
}, },
onLogout = { onLogout = {
haptics.tick() haptics.tick()
messages.clear() newChat()
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
session.logout() session.logout()
} }

View file

@ -46,35 +46,35 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.ui.theme.Amber import dev.kaizen.app.ui.theme.Amber
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
// Mock sidebar history item representing a past conversation /** Date-bucket header for a conversation, from its ISO `updatedAt` (Heute/Gestern/dd. MMM yyyy). */
data class ConvoHistoryItem( private fun dateLabel(updatedAt: String?): String {
val id: String, val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return "FRÜHER"
val title: String, val date = instant.atZone(ZoneId.systemDefault()).toLocalDate()
val dateGroup: String, val today = LocalDate.now()
val locked: Boolean = false, return when (date) {
val pinned: Boolean = false today -> "HEUTE"
) today.minusDays(1) -> "GESTERN"
else -> date.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN)).uppercase()
}
}
// Authentically mirrored mock history from sidebar.png /** Pinned first, then chronological date buckets — preserves the API's desc-updatedAt order. */
val mockConvoHistory = listOf( private fun groupConversations(conversations: List<ConversationSummary>): Map<String, List<ConversationSummary>> {
ConvoHistoryItem("1", "Planetenübersicht P...", "16. JUNI 2026"), val groups = LinkedHashMap<String, MutableList<ConversationSummary>>()
ConvoHistoryItem("2", "Orbitaltheorie in der ...", "16. JUNI 2026", locked = true), conversations.filter { it.pinned }.takeIf { it.isNotEmpty() }?.let { groups["★ ANGEHEFTET"] = it.toMutableList() }
ConvoHistoryItem("3", "Wetter in Magdeburg", "16. JUNI 2026"), conversations.filterNot { it.pinned }.forEach {
ConvoHistoryItem("4", "Aktuelle Uhrzeit in To...", "16. JUNI 2026"), groups.getOrPut(dateLabel(it.updatedAt)) { mutableListOf() }.add(it)
}
ConvoHistoryItem("5", "📷 Aussehen und Styl...", "15. JUNI 2026"), return groups
ConvoHistoryItem("6", "👤 Objektive Bewertu...", "15. JUNI 2026"), }
ConvoHistoryItem("7", "Beziehungen und ihre...", "14. JUNI 2026"),
ConvoHistoryItem("8", "Online-Test erfolgreic...", "12. JUNI 2026"),
ConvoHistoryItem("9", "Hallo Gemini", "11. JUNI 2026"),
ConvoHistoryItem("10", "Gesperrter Chat", "10. JUNI 2026", locked = true)
)
/** /**
* Floating glassmorphic sidebar drawer content. * Floating glassmorphic sidebar drawer content.
@ -85,8 +85,11 @@ val mockConvoHistory = listOf(
@Composable @Composable
fun KaizenSidebar( fun KaizenSidebar(
userName: String, userName: String,
conversations: List<ConversationSummary>,
currentId: String?,
onClose: () -> Unit, onClose: () -> Unit,
onNewChat: () -> Unit, onNewChat: () -> Unit,
onSelectConversation: (ConversationSummary) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -212,27 +215,39 @@ fun KaizenSidebar(
// --- BODY Sektion: Scrollable Chats list grouped by Date --- // --- BODY Sektion: Scrollable Chats list grouped by Date ---
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
val groupedHistory = remember { mockConvoHistory.groupBy { it.dateGroup } } if (conversations.isEmpty()) {
Text(
text = "Noch keine Chats",
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 14.sp,
modifier = Modifier.padding(start = 12.dp, top = 12.dp)
)
} else {
val grouped = remember(conversations) { groupConversations(conversations) }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
groupedHistory.forEach { (dateGroup, items) -> grouped.forEach { (dateGroup, items) ->
// Date header (e.g., 16. JUNI 2026) // Date header (e.g., HEUTE, 16. JUN 2026)
item(key = "header_$dateGroup") { item(key = "header_$dateGroup") {
Text( Text(
text = dateGroup, text = dateGroup,
color = cs.onSurfaceVariant.copy(alpha = 0.45f), color = cs.onSurfaceVariant.copy(alpha = 0.45f),
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp) modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
) )
} }
// History items items(items, key = { it.id }) { item ->
items(items, key = { it.id }) { item -> HistoryItemRow(
HistoryItemRow(item, onClick = { onClose() }) item = item,
selected = item.id == currentId,
onClick = { onSelectConversation(item) },
)
}
} }
} }
} }
@ -321,12 +336,20 @@ fun KaizenSidebar(
} }
@Composable @Composable
private fun HistoryItemRow(item: ConvoHistoryItem, onClick: () -> Unit) { private fun HistoryItemRow(item: ConversationSummary, selected: Boolean, onClick: () -> Unit) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val itemBg = if (isDark) Color(0x0AFFFFFF) else Color(0x05000000) val itemBg = when {
val itemBorder = if (isDark) Color.White.copy(alpha = 0.04f) else Color.Black.copy(alpha = 0.03f) selected -> Amber.copy(alpha = 0.14f)
isDark -> Color(0x0AFFFFFF)
else -> Color(0x05000000)
}
val itemBorder = when {
selected -> Amber.copy(alpha = 0.35f)
isDark -> Color.White.copy(alpha = 0.04f)
else -> Color.Black.copy(alpha = 0.03f)
}
Row( Row(
modifier = Modifier modifier = Modifier

View file

@ -40,6 +40,43 @@ data class KaizenModel(
@Serializable private data class ModelsResponse(val models: List<KaizenModel> = emptyList()) @Serializable private data class ModelsResponse(val models: List<KaizenModel> = emptyList())
/** A conversation as shown in the sidebar list. Extra fields from the API are ignored. */
@Serializable
data class ConversationSummary(
val id: String,
val title: String = "",
val locked: Boolean = false,
val pinned: Boolean = false,
val updatedAt: String? = null,
)
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
@Serializable private data class CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String)
/** One persisted message from GET /api/v1/conversations/[id]. */
@Serializable
data class StoredMessage(
val id: String,
val role: String,
val content: String = "",
)
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
/** A message to persist. `parentId` chains the linear history; `kind` is always "chat" for the app. */
@Serializable
data class SaveMessage(
val id: String,
val parentId: String?,
val role: String,
val content: String,
val kind: String = "chat",
val model: String? = null,
)
@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>)
/** Outcome of an in-app login attempt — surfaced as distinct UI states on the login screen. */ /** Outcome of an in-app login attempt — surfaced as distinct UI states on the login screen. */
sealed interface LoginResult { sealed interface LoginResult {
data class Success(val token: String) : LoginResult data class Success(val token: String) : LoginResult
@ -122,6 +159,90 @@ object KaizenApi {
} }
} }
/** GET /api/v1/conversations — the sidebar list (best-effort, empty on any failure). */
suspend fun fetchConversations(baseUrl: String, token: String): List<ConversationSummary> =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations")
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use emptyList()
json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations
}
} catch (e: IOException) {
emptyList()
}
}
/** POST /api/v1/conversations — create one, returns its id (null on failure). */
suspend fun createConversation(baseUrl: String, token: String, title: String? = null): String? =
withContext(Dispatchers.IO) {
val body = json.encodeToString(CreateConvoRequest(title)).toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations")
.post(body)
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use null
json.decodeFromString<CreatedConversation>(resp.body!!.string()).id
}
} catch (e: IOException) {
null
}
}
/** GET /api/v1/conversations/[id] — the stored messages, in order (empty on failure/locked). */
suspend fun fetchMessages(baseUrl: String, token: String, id: String): List<StoredMessage> =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id")
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use emptyList()
json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages
}
} catch (e: IOException) {
emptyList()
}
}
/** POST /api/v1/conversations/[id]/messages — persist an exchange. Returns success. */
suspend fun saveMessages(baseUrl: String, token: String, id: String, messages: List<SaveMessage>): Boolean =
withContext(Dispatchers.IO) {
val body = json.encodeToString(SaveMessagesRequest(messages)).toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id/messages")
.post(body)
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp -> resp.isSuccessful }
} catch (e: IOException) {
false
}
}
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id/title")
.post("{}".toRequestBody(jsonMedia))
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { }
} catch (e: IOException) {
// best-effort: a missing title is cosmetic
}
}
/** /**
* POST /api/v1/chat streams the assistant reply. Emits the cumulative *visible* * POST /api/v1/chat streams the assistant reply. Emits the cumulative *visible*
* text after each network chunk (sentinels stripped), so the caller just assigns * text after each network chunk (sentinels stripped), so the caller just assigns