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.
This commit is contained in:
Bruno Deanoz 2026-06-19 07:43:59 +02:00
parent aa0a97b959
commit d2f269f33a
6 changed files with 427 additions and 19 deletions

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,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<List<KaizenModel>>(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,11 +233,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 +257,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 +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 },
)
}
}
}
}

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

@ -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<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())
/** 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<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()
}
}
/**
* 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()
}
}