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:
parent
aa0a97b959
commit
d2f269f33a
6 changed files with 427 additions and 19 deletions
|
|
@ -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,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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
269
app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt
Normal file
269
app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt
Normal 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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue