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 27ca6a9..3dd2a09 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -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,9 @@ 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.KaizenApi +import dev.kaizen.app.net.KaizenModel import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.net.WireMessage import dev.kaizen.app.settings.SettingsScreen @@ -87,6 +90,14 @@ 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>(emptyList()) } + var showModelSheet by remember { mutableStateOf(false) } + LaunchedEffect(session.config?.baseUrl, session.config?.token) { + val cfg = session.config ?: return@LaunchedEffect + models = KaizenApi.fetchModels(cfg.baseUrl, cfg.token) + } + fun send(text: String) { val trimmed = text.trim() if (trimmed.isEmpty() || isStreaming) return @@ -107,7 +118,7 @@ fun ChatScreen( haptics.thinkingStart() var sawContent = false try { - KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history).collect { visible -> + 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()) { @@ -222,24 +233,35 @@ 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) - .shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds - .clip(CircleShape) - .background(menuGlassBg) - .border(1.2.dp, menuGlassBorder, CircleShape) - .clickable { haptics.tick(); scope.launch { drawerState.open() } } - .size(44.dp), - contentAlignment = Alignment.Center + .padding(start = 16.dp, top = 10.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = Icons.Rounded.Menu, - contentDescription = "Menü öffnen", - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.size(20.dp) + Box( + modifier = Modifier + .shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds + .clip(CircleShape) + .background(menuGlassBg) + .border(1.2.dp, menuGlassBorder, CircleShape) + .clickable { haptics.tick(); scope.launch { drawerState.open() } } + .size(44.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Menu, + contentDescription = "Menü öffnen", + tint = MaterialTheme.colorScheme.onBackground, + 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), ) } @@ -260,6 +282,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 }, + ) + } } } } diff --git a/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt new file mode 100644 index 0000000..e75bd42 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt @@ -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): 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) + +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, favorites: Set, query: String): List { + 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() + 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, + selectedModelId: String, + favorites: Set, + 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" +} 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 21ab25f..cc6da8c 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -23,7 +23,22 @@ 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) +@Serializable private data class ChatRequest( + val model: String, + val messages: List, + 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 = emptyList()) /** Outcome of an in-app login attempt — surfaced as distinct UI states on the login screen. */ sealed interface LoginResult { @@ -90,14 +105,46 @@ 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 = + 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(resp.body!!.string()).models + } + } catch (e: IOException) { + emptyList() + } + } + /** * 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): Flow = flow { - val payload = json.encodeToString(ChatRequest(model, messages)).toRequestBody(jsonMedia) + fun chat( + baseUrl: String, + token: String, + model: String, + messages: List, + speed: ChatSpeed = ChatSpeed.Normal, + ): Flow = 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) diff --git a/app/src/main/java/dev/kaizen/app/net/SecureStore.kt b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt index 23ff56d..12b8831 100644 --- a/app/src/main/java/dev/kaizen/app/net/SecureStore.kt +++ b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt @@ -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 = prefs.getStringSet(KEY_FAVORITES, emptySet())!!.toSet() + + fun saveFavorites(ids: Set) { + 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" } } diff --git a/app/src/main/java/dev/kaizen/app/net/ServerConfig.kt b/app/src/main/java/dev/kaizen/app/net/ServerConfig.kt index 0499ad4..d41b26f 100644 --- a/app/src/main/java/dev/kaizen/app/net/ServerConfig.kt +++ b/app/src/main/java/dev/kaizen/app/net/ServerConfig.kt @@ -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). */ diff --git a/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt index 01cf916..3a461b0 100644 --- a/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt +++ b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt @@ -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() } }