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.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
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.BlobBlue
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. */
@Composable
@ -66,7 +69,17 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
animationSpec = infiniteRepeatable(tween(22000, easing = LinearEasing), RepeatMode.Reverse),
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(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)

View file

@ -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)

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -49,7 +50,11 @@ import androidx.compose.ui.platform.LocalConfiguration
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
@ -87,16 +92,67 @@ fun ChatScreen(
// Native sidebar drawer state
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) {
val trimmed = text.trim()
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
@ -107,7 +163,12 @@ fun ChatScreen(
haptics.thinkingStart()
var sawContent = false
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 }
if (idx < 0) return@collect
if (!sawContent && visible.isNotEmpty()) {
@ -120,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(
@ -157,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 = {
@ -170,7 +252,7 @@ fun ChatScreen(
},
onLogout = {
haptics.tick()
messages.clear()
newChat()
scope.launch { drawerState.close() }
session.logout()
}
@ -222,11 +304,15 @@ fun ChatScreen(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f)))
}
Box(
Row(
modifier = Modifier
.align(Alignment.TopStart)
.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
.clip(CircleShape)
.background(menuGlassBg)
@ -242,6 +328,13 @@ fun ChatScreen(
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)
Column(
@ -260,6 +353,20 @@ fun ChatScreen(
)
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.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<ConversationSummary>): Map<String, List<ConversationSummary>> {
val groups = LinkedHashMap<String, MutableList<ConversationSummary>>()
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<ConversationSummary>,
currentId: String?,
onClose: () -> Unit,
onNewChat: () -> Unit,
onSelectConversation: (ConversationSummary) -> Unit,
onOpenSettings: () -> Unit,
onLogout: () -> Unit,
modifier: Modifier = Modifier
@ -212,14 +215,22 @@ fun KaizenSidebar(
// --- BODY Sektion: Scrollable Chats list grouped by Date ---
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(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
groupedHistory.forEach { (dateGroup, items) ->
// Date header (e.g., 16. JUNI 2026)
grouped.forEach { (dateGroup, items) ->
// Date header (e.g., HEUTE, 16. JUN 2026)
item(key = "header_$dateGroup") {
Text(
text = dateGroup,
@ -230,9 +241,13 @@ fun KaizenSidebar(
)
}
// History items
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
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

View file

@ -23,7 +23,59 @@ import java.util.concurrent.TimeUnit
@Serializable private data class TokenResponse(val token: String)
@Serializable private data class MeResponse(val defaultModel: String? = null)
@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. */
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*
* 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
* 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 {
val payload = json.encodeToString(ChatRequest(model, messages)).toRequestBody(jsonMedia)
fun chat(
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()
.url("$baseUrl/api/v1/chat")
.post(payload)

View file

@ -33,7 +33,9 @@ class SecureStore(context: Context) {
val token = prefs.getString(KEY_TOKEN, null) ?: return null
val model = prefs.getString(KEY_MODEL, null) ?: DEFAULT_MODEL
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) {
@ -42,9 +44,17 @@ class SecureStore(context: Context) {
.putString(KEY_TOKEN, config.token)
.putString(KEY_MODEL, config.model)
.putString(KEY_EMAIL, config.email)
.putString(KEY_SPEED, config.speed.name)
.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() {
prefs.edit().clear().apply()
}
@ -54,5 +64,7 @@ class SecureStore(context: Context) {
const val KEY_TOKEN = "token"
const val KEY_MODEL = "model"
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
/**
* 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
* 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 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 speed response-speed preset sent with each chat request
*/
data class ServerConfig(
val baseUrl: String,
val token: String,
val model: String,
val email: String,
val speed: ChatSpeed = ChatSpeed.Normal,
)
/** 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())
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
/**
@ -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() {
store.clear()
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)) }
}