From ea5e625f0fb04f352e6318e7b348c89bd284c614 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Fri, 19 Jun 2026 07:58:07 +0200 Subject: [PATCH] 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). --- .../java/dev/kaizen/app/chat/ChatModels.kt | 3 + .../java/dev/kaizen/app/chat/ChatScreen.kt | 79 ++++++++++- .../main/java/dev/kaizen/app/chat/Sidebar.kt | 125 +++++++++++------- .../main/java/dev/kaizen/app/net/KaizenApi.kt | 121 +++++++++++++++++ 4 files changed, 273 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt index aa21883..1e811ed 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt @@ -18,6 +18,9 @@ data class Message( val content: String, val streaming: 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) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 3dd2a09..a4cd84b 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -51,8 +51,10 @@ import androidx.compose.ui.unit.dp import dev.kaizen.app.haptics.rememberHaptics import dev.kaizen.app.net.ChatHttpException import dev.kaizen.app.net.ChatSpeed +import dev.kaizen.app.net.ConversationSummary import dev.kaizen.app.net.KaizenApi import dev.kaizen.app.net.KaizenModel +import dev.kaizen.app.net.SaveMessage import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.net.WireMessage import dev.kaizen.app.settings.SettingsScreen @@ -93,9 +95,47 @@ fun ChatScreen( // Model picker: catalog (best-effort fetch) + sheet visibility var models by remember { mutableStateOf>(emptyList()) } var showModelSheet by remember { mutableStateOf(false) } + + // Conversation persistence: the active server conversation + the sidebar list + var conversationId by remember { mutableStateOf(null) } + var conversations by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(session.config?.baseUrl, session.config?.token) { val cfg = session.config ?: return@LaunchedEffect 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) { @@ -103,11 +143,16 @@ fun ChatScreen( if (trimmed.isEmpty() || isStreaming) return val cfg = session.config ?: return 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 = "" 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 + val wasNewConversation = conversationId == null // Wire history = the visible conversation minus the streaming placeholder. val history = messages @@ -118,6 +163,11 @@ fun ChatScreen( haptics.thinkingStart() var sawContent = false 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 -> val idx = messages.indexOfLast { it.id == assistantId } if (idx < 0) return@collect @@ -131,7 +181,21 @@ fun ChatScreen( ) } 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) + + // 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) { val idx = messages.indexOfLast { it.id == assistantId } if (idx >= 0) messages[idx] = messages[idx].copy( @@ -168,10 +232,17 @@ fun ChatScreen( drawerContent = { KaizenSidebar( userName = userName, + conversations = conversations, + currentId = conversationId, onClose = { scope.launch { drawerState.close() } }, onNewChat = { haptics.tick() - messages.clear() + newChat() + scope.launch { drawerState.close() } + }, + onSelectConversation = { summary -> + haptics.tick() + openConversation(summary) scope.launch { drawerState.close() } }, onOpenSettings = { @@ -181,7 +252,7 @@ fun ChatScreen( }, onLogout = { haptics.tick() - messages.clear() + newChat() scope.launch { drawerState.close() } session.logout() } diff --git a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt index 9b5cc84..16d0763 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt @@ -46,35 +46,35 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import dev.kaizen.app.net.ConversationSummary 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 -data class ConvoHistoryItem( - val id: String, - val title: String, - val dateGroup: String, - val locked: Boolean = false, - val pinned: Boolean = false -) +/** Date-bucket header for a conversation, from its ISO `updatedAt` (Heute/Gestern/dd. MMM yyyy). */ +private fun dateLabel(updatedAt: String?): String { + val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return "FRÜHER" + val date = instant.atZone(ZoneId.systemDefault()).toLocalDate() + val today = LocalDate.now() + return when (date) { + today -> "HEUTE" + today.minusDays(1) -> "GESTERN" + else -> date.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN)).uppercase() + } +} -// Authentically mirrored mock history from sidebar.png -val mockConvoHistory = listOf( - ConvoHistoryItem("1", "Planetenübersicht P...", "16. JUNI 2026"), - ConvoHistoryItem("2", "Orbitaltheorie in der ...", "16. JUNI 2026", locked = true), - ConvoHistoryItem("3", "Wetter in Magdeburg", "16. JUNI 2026"), - ConvoHistoryItem("4", "Aktuelle Uhrzeit in To...", "16. JUNI 2026"), - - ConvoHistoryItem("5", "📷 Aussehen und Styl...", "15. JUNI 2026"), - 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) -) +/** Pinned first, then chronological date buckets — preserves the API's desc-updatedAt order. */ +private fun groupConversations(conversations: List): Map> { + val groups = LinkedHashMap>() + conversations.filter { it.pinned }.takeIf { it.isNotEmpty() }?.let { groups["★ ANGEHEFTET"] = it.toMutableList() } + conversations.filterNot { it.pinned }.forEach { + groups.getOrPut(dateLabel(it.updatedAt)) { mutableListOf() }.add(it) + } + return groups +} /** * Floating glassmorphic sidebar drawer content. @@ -85,8 +85,11 @@ val mockConvoHistory = listOf( @Composable fun KaizenSidebar( userName: String, + conversations: List, + currentId: String?, onClose: () -> Unit, onNewChat: () -> Unit, + onSelectConversation: (ConversationSummary) -> Unit, onOpenSettings: () -> Unit, onLogout: () -> Unit, modifier: Modifier = Modifier @@ -212,27 +215,39 @@ fun KaizenSidebar( // --- BODY Sektion: Scrollable Chats list grouped by Date --- Box(Modifier.weight(1f).fillMaxWidth()) { - val groupedHistory = remember { mockConvoHistory.groupBy { it.dateGroup } } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - groupedHistory.forEach { (dateGroup, items) -> - // Date header (e.g., 16. JUNI 2026) - item(key = "header_$dateGroup") { - Text( - text = dateGroup, - color = cs.onSurfaceVariant.copy(alpha = 0.45f), - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp) - ) - } - - // History items - items(items, key = { it.id }) { item -> - HistoryItemRow(item, onClick = { onClose() }) + 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( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + grouped.forEach { (dateGroup, items) -> + // Date header (e.g., HEUTE, 16. JUN 2026) + item(key = "header_$dateGroup") { + Text( + text = dateGroup, + color = cs.onSurfaceVariant.copy(alpha = 0.45f), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp) + ) + } + + items(items, key = { it.id }) { item -> + HistoryItemRow( + item = item, + selected = item.id == currentId, + onClick = { onSelectConversation(item) }, + ) + } } } } @@ -321,12 +336,20 @@ fun KaizenSidebar( } @Composable -private fun HistoryItemRow(item: ConvoHistoryItem, onClick: () -> Unit) { +private fun HistoryItemRow(item: ConversationSummary, selected: Boolean, onClick: () -> Unit) { val cs = MaterialTheme.colorScheme val isDark = isSystemInDarkTheme() - - val itemBg = if (isDark) Color(0x0AFFFFFF) else Color(0x05000000) - val itemBorder = if (isDark) Color.White.copy(alpha = 0.04f) else Color.Black.copy(alpha = 0.03f) + + val itemBg = when { + 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( modifier = Modifier diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index cc6da8c..b70894e 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -40,6 +40,43 @@ data class KaizenModel( @Serializable private data class ModelsResponse(val models: List = 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 = 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 = 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) + /** Outcome of an in-app login attempt — surfaced as distinct UI states on the login screen. */ sealed interface 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 = + 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(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(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 = + 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(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): 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* * text after each network chunk (sentinels stripped), so the caller just assigns