Compare commits

..

3 commits

Author SHA1 Message Date
Bruno Deanoz
ea5e625f0f 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).
2026-06-19 07:58:07 +02:00
Bruno Deanoz
d2f269f33a feat(chat): model + speed picker (top pill + bottom sheet)
Top pill shows the active model; tapping opens a bottom sheet with a
response-speed segment (Instant/Normal/Max -> reasoningEffort + throughput),
model search, favorites, provider grouping and a Vertex hoster badge.
Model/speed/favorites persisted in the encrypted store; chat now sends the
additive reasoningEffort/preferThroughput v1 fields.
2026-06-19 07:43:59 +02:00
Bruno Deanoz
aa0a97b959 fix(ui): dither gradients to kill 8-bit sRGB banding on orb + mesh 2026-06-19 07:27:33 +02:00
10 changed files with 766 additions and 75 deletions

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
@ -51,6 +52,8 @@ import dev.kaizen.app.ui.theme.AmberDeep
import dev.kaizen.app.ui.theme.BlobAmber import dev.kaizen.app.ui.theme.BlobAmber
import dev.kaizen.app.ui.theme.BlobBlue import dev.kaizen.app.ui.theme.BlobBlue
import dev.kaizen.app.ui.theme.BlobTeal import dev.kaizen.app.ui.theme.BlobTeal
import dev.kaizen.app.ui.theme.DITHER_ALPHA
import dev.kaizen.app.ui.theme.rememberDitherBrush
/** Mesh/blob background: large, heavily blurred color circles that drift slowly. */ /** Mesh/blob background: large, heavily blurred color circles that drift slowly. */
@Composable @Composable
@ -66,7 +69,17 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
animationSpec = infiniteRepeatable(tween(22000, easing = LinearEasing), RepeatMode.Reverse), animationSpec = infiniteRepeatable(tween(22000, easing = LinearEasing), RepeatMode.Reverse),
label = "drift", label = "drift",
) )
Box(Modifier.fillMaxSize().background(base)) { // Dither overlay (drawn last, over everything) — kills 8-bit gradient banding.
val ditherBrush = rememberDitherBrush()
Box(
Modifier
.fillMaxSize()
.background(base)
.drawWithContent {
drawContent()
drawRect(brush = ditherBrush, alpha = DITHER_ALPHA)
},
) {
Blob(BlobAmber, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f * factor) Blob(BlobAmber, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f * factor)
Blob(BlobBlue, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f * factor) Blob(BlobBlue, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f * factor)
Blob(BlobTeal, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor) Blob(BlobTeal, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor)

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

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -49,7 +50,11 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp 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.ConversationSummary
import dev.kaizen.app.net.KaizenApi 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.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
@ -87,16 +92,67 @@ fun ChatScreen(
// Native sidebar drawer state // Native sidebar drawer state
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
// Model picker: catalog (best-effort fetch) + sheet visibility
var models by remember { mutableStateOf<List<KaizenModel>>(emptyList()) }
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) {
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) { fun send(text: String) {
val trimmed = text.trim() val trimmed = text.trim()
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
@ -107,7 +163,12 @@ fun ChatScreen(
haptics.thinkingStart() haptics.thinkingStart()
var sawContent = false var sawContent = false
try { try {
KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history).collect { visible -> // 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 } val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect if (idx < 0) return@collect
if (!sawContent && visible.isNotEmpty()) { if (!sawContent && visible.isNotEmpty()) {
@ -120,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(
@ -157,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 = {
@ -170,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()
} }
@ -222,11 +304,15 @@ fun ChatScreen(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f))) Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f)))
} }
Box( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.statusBarsPadding() .statusBarsPadding()
.padding(start = 16.dp, top = 10.dp) .padding(start = 16.dp, top = 10.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds .shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds
.clip(CircleShape) .clip(CircleShape)
.background(menuGlassBg) .background(menuGlassBg)
@ -242,6 +328,13 @@ fun ChatScreen(
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
} }
Spacer(Modifier.width(8.dp))
ModelPill(
label = modelDisplayName(session.config?.model ?: "", models),
onClick = { haptics.tick(); showModelSheet = true },
modifier = Modifier.weight(1f, fill = false),
)
}
// 3. Floating Bottom Control Dock (responsive centering and clear bottom margins) // 3. Floating Bottom Control Dock (responsive centering and clear bottom margins)
Column( Column(
@ -260,6 +353,20 @@ fun ChatScreen(
) )
Spacer(Modifier.height(16.dp)) // Completely clears keyboard edges on all screens Spacer(Modifier.height(16.dp)) // Completely clears keyboard edges on all screens
} }
// Model + speed picker (bottom sheet), opened by the top pill
if (showModelSheet) {
ModelSheet(
models = models,
selectedModelId = session.config?.model ?: "",
favorites = session.favorites,
speed = session.config?.speed ?: ChatSpeed.Normal,
onSelectModel = { session.setModel(it); haptics.tick() },
onToggleFavorite = { session.toggleFavorite(it) },
onSelectSpeed = { session.setSpeed(it); haptics.tick() },
onDismiss = { showModelSheet = false },
)
}
} }
} }
} }

View file

@ -0,0 +1,269 @@
package dev.kaizen.app.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.ChatSpeed
import dev.kaizen.app.net.KaizenModel
import dev.kaizen.app.ui.theme.Amber
/** Human label + composite-id resolver for the top pill — falls back to a de-prefixed id. */
fun modelDisplayName(modelId: String, models: List<KaizenModel>): String =
models.firstOrNull { it.id == modelId }?.name ?: modelId.substringAfter(":")
/** The glass top pill showing the active model — tap opens [ModelSheet]. */
@Composable
fun ModelPill(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme
Row(
modifier = modifier
.clip(RoundedCornerShape(22.dp))
.background(cs.surface.copy(alpha = 0.7f))
.border(1.dp, cs.onSurface.copy(alpha = 0.08f), RoundedCornerShape(22.dp))
.clickable { onClick() }
.padding(start = 14.dp, end = 8.dp, top = 9.dp, bottom = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
color = cs.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
Icon(
Icons.Rounded.ExpandMore,
contentDescription = null,
tint = cs.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
/** A logical group of models in the picker (Favorites, Vertex AI, then by publisher). */
private data class ModelGroup(val label: String, val models: List<KaizenModel>)
private fun groupFor(m: KaizenModel): String = when {
m.provider == "vertex" -> "Vertex AI"
else -> m.id.substringBefore("/", "").ifEmpty { "Andere" }
.replaceFirstChar { it.uppercase() }
}
private fun buildGroups(models: List<KaizenModel>, favorites: Set<String>, query: String): List<ModelGroup> {
val q = query.trim().lowercase()
val filtered = if (q.isEmpty()) models
else models.filter { it.name.lowercase().contains(q) || it.id.lowercase().contains(q) }
val groups = mutableListOf<ModelGroup>()
val favs = filtered.filter { it.id in favorites }
if (favs.isNotEmpty()) groups += ModelGroup("★ Favoriten", favs)
// Vertex first, then publishers alphabetically — favorites still appear in their home group too.
filtered.groupBy { groupFor(it) }
.toSortedMap(compareBy({ if (it == "Vertex AI") "" else it.lowercase() }))
.forEach { (label, list) -> groups += ModelGroup(label, list.sortedBy { it.name }) }
return groups
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModelSheet(
models: List<KaizenModel>,
selectedModelId: String,
favorites: Set<String>,
speed: ChatSpeed,
onSelectModel: (String) -> Unit,
onToggleFavorite: (String) -> Unit,
onSelectSpeed: (ChatSpeed) -> Unit,
onDismiss: () -> Unit,
) {
val cs = MaterialTheme.colorScheme
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var query by remember { mutableStateOf("") }
val groups = remember(models, favorites, query) { buildGroups(models, favorites, query) }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
// --- Response speed segment ---
Text(
"Geschwindigkeit",
color = cs.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp),
)
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
val options = ChatSpeed.entries
options.forEachIndexed { i, opt ->
SegmentedButton(
selected = speed == opt,
onClick = { onSelectSpeed(opt) },
shape = SegmentedButtonDefaults.itemShape(i, options.size),
) { Text(speedLabel(opt)) }
}
}
Spacer(Modifier.size(16.dp))
// --- Search ---
TextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Modell suchen…") },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
)
Spacer(Modifier.size(12.dp))
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 460.dp)) {
if (groups.isEmpty()) {
item {
Text(
if (models.isEmpty()) "Modelle werden geladen…" else "Keine Treffer",
color = cs.onSurfaceVariant,
fontSize = 14.sp,
modifier = Modifier.padding(vertical = 24.dp),
)
}
}
groups.forEach { group ->
item(key = "h_${group.label}") {
Text(
group.label,
color = cs.onSurfaceVariant.copy(alpha = 0.6f),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 14.dp, bottom = 6.dp, start = 4.dp),
)
}
items(group.models, key = { "${group.label}_${it.id}" }) { model ->
ModelRow(
model = model,
selected = model.id == selectedModelId,
favorite = model.id in favorites,
onClick = { onSelectModel(model.id); onDismiss() },
onToggleFavorite = { onToggleFavorite(model.id) },
)
}
}
item { Spacer(Modifier.size(24.dp)) }
}
}
}
}
@Composable
private fun ModelRow(
model: KaizenModel,
selected: Boolean,
favorite: Boolean,
onClick: () -> Unit,
onToggleFavorite: () -> Unit,
) {
val cs = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(if (selected) Amber.copy(alpha = 0.12f) else Color.Transparent)
.clickable { onClick() }
.padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.clickable { onToggleFavorite() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Rounded.Star,
contentDescription = "Favorit",
tint = if (favorite) Amber else cs.onSurfaceVariant.copy(alpha = 0.35f),
modifier = Modifier.size(18.dp),
)
}
Spacer(Modifier.size(4.dp))
Text(
model.name,
color = cs.onBackground,
fontSize = 15.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier.weight(1f),
)
if (model.provider == "vertex") {
VertexBadge()
Spacer(Modifier.size(8.dp))
}
if (selected) {
Icon(Icons.Rounded.Check, contentDescription = "Ausgewählt", tint = Amber, modifier = Modifier.size(20.dp))
}
}
}
@Composable
private fun VertexBadge() {
Box(
Modifier
.clip(RoundedCornerShape(6.dp))
.background(Amber.copy(alpha = 0.16f))
.padding(horizontal = 7.dp, vertical = 2.dp),
) {
Text("Vertex", color = Amber, fontSize = 10.sp, fontWeight = FontWeight.Bold)
}
}
private fun speedLabel(s: ChatSpeed): String = when (s) {
ChatSpeed.Instant -> "Instant"
ChatSpeed.Normal -> "Normal"
ChatSpeed.Max -> "Max"
}

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,14 +215,22 @@ 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,
@ -230,9 +241,13 @@ fun KaizenSidebar(
) )
} }
// History items
items(items, key = { it.id }) { item -> items(items, key = { it.id }) { item ->
HistoryItemRow(item, onClick = { onClose() }) HistoryItemRow(
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

@ -23,7 +23,59 @@ import java.util.concurrent.TimeUnit
@Serializable private data class TokenResponse(val token: String) @Serializable private data class TokenResponse(val token: String)
@Serializable private data class MeResponse(val defaultModel: String? = null) @Serializable private data class MeResponse(val defaultModel: String? = null)
@Serializable data class WireMessage(val role: String, val content: String) @Serializable data class WireMessage(val role: String, val content: String)
@Serializable private data class ChatRequest(val model: String, val messages: List<WireMessage>) @Serializable private data class ChatRequest(
val model: String,
val messages: List<WireMessage>,
val reasoningEffort: String? = null,
val preferThroughput: Boolean? = null,
)
/** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */
@Serializable
data class KaizenModel(
val id: String,
val name: String,
val provider: String? = null,
)
@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 {
@ -90,14 +142,130 @@ object KaizenApi {
} }
} }
/** GET /api/v1/models — the full model catalog for the picker (best-effort, empty on any failure). */
suspend fun fetchModels(baseUrl: String, token: String): List<KaizenModel> =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/models")
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use emptyList()
json.decodeFromString<ModelsResponse>(resp.body!!.string()).models
}
} catch (e: IOException) {
emptyList()
}
}
/** 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
* it to the message content. The full byte buffer is re-decoded each chunk to keep * it to the message content. The full byte buffer is re-decoded each chunk to keep
* UTF-8 multi-byte sequences that span chunk boundaries intact. * UTF-8 multi-byte sequences that span chunk boundaries intact.
*
* [speed] maps to the additive `reasoningEffort`/`preferThroughput` v1 fields.
*/ */
fun chat(baseUrl: String, token: String, model: String, messages: List<WireMessage>): Flow<String> = flow { fun chat(
val payload = json.encodeToString(ChatRequest(model, messages)).toRequestBody(jsonMedia) baseUrl: String,
token: String,
model: String,
messages: List<WireMessage>,
speed: ChatSpeed = ChatSpeed.Normal,
): Flow<String> = flow {
val payload = json.encodeToString(
ChatRequest(
model = model,
messages = messages,
reasoningEffort = speed.reasoningEffort,
preferThroughput = if (speed.preferThroughput) true else null,
),
).toRequestBody(jsonMedia)
val req = Request.Builder() val req = Request.Builder()
.url("$baseUrl/api/v1/chat") .url("$baseUrl/api/v1/chat")
.post(payload) .post(payload)

View file

@ -33,7 +33,9 @@ class SecureStore(context: Context) {
val token = prefs.getString(KEY_TOKEN, null) ?: return null val token = prefs.getString(KEY_TOKEN, null) ?: return null
val model = prefs.getString(KEY_MODEL, null) ?: DEFAULT_MODEL val model = prefs.getString(KEY_MODEL, null) ?: DEFAULT_MODEL
val email = prefs.getString(KEY_EMAIL, null) ?: "" val email = prefs.getString(KEY_EMAIL, null) ?: ""
return ServerConfig(baseUrl, token, model, email) val speed = prefs.getString(KEY_SPEED, null)
?.let { runCatching { ChatSpeed.valueOf(it) }.getOrNull() } ?: ChatSpeed.Normal
return ServerConfig(baseUrl, token, model, email, speed)
} }
fun save(config: ServerConfig) { fun save(config: ServerConfig) {
@ -42,9 +44,17 @@ class SecureStore(context: Context) {
.putString(KEY_TOKEN, config.token) .putString(KEY_TOKEN, config.token)
.putString(KEY_MODEL, config.model) .putString(KEY_MODEL, config.model)
.putString(KEY_EMAIL, config.email) .putString(KEY_EMAIL, config.email)
.putString(KEY_SPEED, config.speed.name)
.apply() .apply()
} }
/** Favorite model ids — pinned to the top of the picker. Not sensitive, but lives here to share one store. */
fun loadFavorites(): Set<String> = prefs.getStringSet(KEY_FAVORITES, emptySet())!!.toSet()
fun saveFavorites(ids: Set<String>) {
prefs.edit().putStringSet(KEY_FAVORITES, ids).apply()
}
fun clear() { fun clear() {
prefs.edit().clear().apply() prefs.edit().clear().apply()
} }
@ -54,5 +64,7 @@ class SecureStore(context: Context) {
const val KEY_TOKEN = "token" const val KEY_TOKEN = "token"
const val KEY_MODEL = "model" const val KEY_MODEL = "model"
const val KEY_EMAIL = "email" const val KEY_EMAIL = "email"
const val KEY_SPEED = "speed"
const val KEY_FAVORITES = "favorite_models"
} }
} }

View file

@ -1,5 +1,17 @@
package dev.kaizen.app.net package dev.kaizen.app.net
/**
* Response speed the user-facing framing of the backend's reasoning/throughput
* knobs (`ChatOptions.reasoningEffort` + `preferThroughput`). [Normal] sends no
* override (provider default); [Instant] is the web "instant" preset (no reasoning
* + `:nitro` throughput routing); [Max] asks for high reasoning effort.
*/
enum class ChatSpeed(val reasoningEffort: String?, val preferThroughput: Boolean) {
Instant(reasoningEffort = "none", preferThroughput = true),
Normal(reasoningEffort = null, preferThroughput = false),
Max(reasoningEffort = "high", preferThroughput = false),
}
/** /**
* The app is deployment-agnostic: one binary that points at EITHER the official * The app is deployment-agnostic: one binary that points at EITHER the official
* server OR any self-hosted instance, via one uniform `Authorization: Bearer` * server OR any self-hosted instance, via one uniform `Authorization: Bearer`
@ -9,12 +21,14 @@ package dev.kaizen.app.net
* @param token the `kzn_...` personal access token (stored encrypted, never logged) * @param token the `kzn_...` personal access token (stored encrypted, never logged)
* @param model composite model id sent to /api/v1/chat, e.g. "vertex:gemini-2.5-flash" * @param model composite model id sent to /api/v1/chat, e.g. "vertex:gemini-2.5-flash"
* @param email the signed-in email (for display in the sidebar/settings) * @param email the signed-in email (for display in the sidebar/settings)
* @param speed response-speed preset sent with each chat request
*/ */
data class ServerConfig( data class ServerConfig(
val baseUrl: String, val baseUrl: String,
val token: String, val token: String,
val model: String, val model: String,
val email: String, val email: String,
val speed: ChatSpeed = ChatSpeed.Normal,
) )
/** Default origin pre-filled on the login screen (editable — self-host points it elsewhere). */ /** Default origin pre-filled on the login screen (editable — self-host points it elsewhere). */

View file

@ -17,6 +17,10 @@ class SessionViewModel(private val store: SecureStore) {
var config by mutableStateOf(store.load()) var config by mutableStateOf(store.load())
private set private set
/** Favorite model ids (pinned to the top of the picker), as Compose snapshot state. */
var favorites by mutableStateOf(store.loadFavorites())
private set
val isLoggedIn: Boolean get() = config != null val isLoggedIn: Boolean get() = config != null
/** /**
@ -38,8 +42,34 @@ class SessionViewModel(private val store: SecureStore) {
} }
} }
/** Switch the chat model (persisted). No-op if not logged in. */
fun setModel(modelId: String) {
val cfg = config ?: return
if (cfg.model == modelId) return
val updated = cfg.copy(model = modelId)
store.save(updated)
config = updated
}
/** Switch the response-speed preset (persisted). */
fun setSpeed(speed: ChatSpeed) {
val cfg = config ?: return
if (cfg.speed == speed) return
val updated = cfg.copy(speed = speed)
store.save(updated)
config = updated
}
/** Toggle a model id in/out of favorites (persisted). */
fun toggleFavorite(modelId: String) {
val next = favorites.toMutableSet().apply { if (!add(modelId)) remove(modelId) }
store.saveFavorites(next)
favorites = next
}
fun logout() { fun logout() {
store.clear() store.clear()
config = null config = null
favorites = emptySet()
} }
} }

View file

@ -0,0 +1,52 @@
package dev.kaizen.app.ui.theme
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.asImageBitmap
import kotlin.random.Random
/**
* Cross-version gradient dither.
*
* 8-bit sRGB radial gradients quantize into visible concentric rings (the
* "verpickelt"/banding look on the orb and mesh blobs). Overlaying a tiled,
* ~1-LSB black/white/transparent noise pattern trades those hard bands for
* imperceptible fine grain the standard fix that works on every API level
* (no AGSL / API-33 gating needed, so it covers minSdk 26).
*
* The pattern is deterministic (fixed seed) so it never shimmers between
* recompositions; ~ black, white, transparent makes the perturbation
* symmetric (darkens and brightens equally) at a low overall [DITHER_ALPHA].
*
* [DITHER_ALPHA] is the single tuning knob: higher = bands vanish faster but
* fine grain creeps in (worst on the near-black Obsidian background); lower =
* cleaner but bands may survive. ~0.0120.018 is the sweet spot dial on device.
*/
const val DITHER_ALPHA = 0.015f
private fun buildNoiseBitmap(size: Int = 160): ImageBitmap {
val pixels = IntArray(size * size)
val rnd = Random(0x4A1B)
for (i in pixels.indices) {
pixels[i] = when (rnd.nextInt(3)) {
0 -> 0xFF000000.toInt() // darken
1 -> 0xFFFFFFFF.toInt() // brighten
else -> 0x00000000 // leave untouched
}
}
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
bmp.setPixels(pixels, 0, size, 0, 0, size, size)
return bmp.asImageBitmap()
}
/** A tiled noise [ShaderBrush] for dithering. Build once, fill the whole surface at [DITHER_ALPHA]. */
@Composable
fun rememberDitherBrush(): ShaderBrush {
val image = remember { buildNoiseBitmap() }
return remember(image) { ShaderBrush(ImageShader(image, TileMode.Repeated, TileMode.Repeated)) }
}