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.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,9 @@ 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.KaizenApi import dev.kaizen.app.net.KaizenApi
import dev.kaizen.app.net.KaizenModel
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,6 +90,14 @@ 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) }
LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect
models = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)
}
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
@ -107,7 +118,7 @@ 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 -> 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()) {
@ -222,11 +233,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 +257,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 +282,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

@ -23,7 +23,22 @@ 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())
/** 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 +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* * 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()
} }
} }