From f6dc5db3631d407405cfcda1319d42797e19fd1e Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Mon, 22 Jun 2026 00:19:14 +0200 Subject: [PATCH] reasoning presets + sampling controls (functional, not mock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reasoning pill (Brain icon + dropdown): 8 presets matching web (Sofort/Aus/Minimal/Niedrig/Standard/Mittel/Hoch/Maximal). "Standard" = no override, "Sofort" = no reasoning + throughput. Sends reasoningEffort + preferThroughput in ChatRequest. Sampling pill (Tune icon + popover): per-parameter toggle + slider for Temperature (0-2), Top-P (0-1), Top-K (1-100). Each parameter independently on/off — only enabled ones are sent. Accent highlight when any param is enabled. Both controls are separate from mode pills (Bild/Suche/Video) and work in chat + search modes, matching the web layout. ChatRequest extended with temperature/topP/topK fields. Old ChatSpeed enum no longer used for reasoning (kept for backward compat with ServerConfig). --- .../dev/kaizen/app/chat/ChatComponents.kt | 137 ++++++++++++++++-- .../java/dev/kaizen/app/chat/ChatModels.kt | 19 +++ .../java/dev/kaizen/app/chat/ChatScreen.kt | 13 +- .../main/java/dev/kaizen/app/net/KaizenApi.kt | 16 +- app/src/main/res/values-en/strings.xml | 15 ++ app/src/main/res/values/strings.xml | 15 ++ 6 files changed, 199 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index 26b7cd3..8a68160 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -7,6 +7,11 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -82,6 +87,10 @@ import androidx.compose.material.icons.rounded.PictureAsPdf import androidx.compose.material.icons.rounded.VideoFile import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Psychology +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale @@ -142,11 +151,25 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M } @Composable -fun ModePillsRow(activeMode: Int?, onToggle: (Int) -> Unit, modifier: Modifier = Modifier) { +fun ModePillsRow( + activeMode: Int?, + onToggle: (Int) -> Unit, + reasoningPreset: ReasoningPreset, + onReasoningChange: (ReasoningPreset) -> Unit, + samplingPrefs: SamplingPrefs, + onSamplingChange: (SamplingPrefs) -> Unit, + modifier: Modifier = Modifier, +) { val cs = MaterialTheme.colorScheme val isDark = isSystemInDarkTheme() val accent = LocalKaizenAccent.current + var showReasoningMenu by remember { mutableStateOf(false) } + var showSamplingPopup by remember { mutableStateOf(false) } + + val inactiveBg = if (isDark) cs.surface.copy(alpha = 0.35f) else cs.surface.copy(alpha = 0.55f) + val inactiveBorder = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f) + Row( modifier .fillMaxWidth() @@ -154,19 +177,87 @@ fun ModePillsRow(activeMode: Int?, onToggle: (Int) -> Unit, modifier: Modifier = .padding(horizontal = 14.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - chatModes.filter { it.labelRes != R.string.mode_standard }.forEach { mode -> + // Reasoning pill (dropdown) + Box { + Row( + Modifier + .clip(KaizenShapes.pill) + .background(inactiveBg) + .border(1.2.dp, inactiveBorder, KaizenShapes.pill) + .clickable { showReasoningMenu = true } + .padding(horizontal = 13.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Rounded.Psychology, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(reasoningPreset.label), color = cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.width(4.dp)) + Icon(Icons.Rounded.ExpandMore, null, tint = cs.onSurfaceVariant.copy(alpha = 0.5f), modifier = Modifier.size(14.dp)) + } + DropdownMenu(expanded = showReasoningMenu, onDismissRequest = { showReasoningMenu = false }) { + ReasoningPreset.entries.forEach { preset -> + DropdownMenuItem( + text = { + Text( + stringResource(preset.label), + fontSize = 14.sp, + color = if (preset == reasoningPreset) accent.primary else cs.onBackground, + fontWeight = if (preset == reasoningPreset) FontWeight.SemiBold else FontWeight.Normal, + ) + }, + onClick = { onReasoningChange(preset); showReasoningMenu = false }, + ) + } + } + } + + // Sampling pill (popover) + Box { + val anyEnabled = samplingPrefs.temperature.enabled || samplingPrefs.topP.enabled || samplingPrefs.topK.enabled + Row( + Modifier + .clip(KaizenShapes.pill) + .background(if (anyEnabled) accent.primary.copy(alpha = if (isDark) 0.18f else 0.14f) else inactiveBg) + .border(1.2.dp, if (anyEnabled) accent.primary.copy(alpha = 0.4f) else inactiveBorder, KaizenShapes.pill) + .clickable { showSamplingPopup = !showSamplingPopup } + .padding(horizontal = 13.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Rounded.Tune, null, tint = if (anyEnabled) accent.primary else cs.onSurfaceVariant, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.mode_sampling), color = if (anyEnabled) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.width(4.dp)) + Icon(Icons.Rounded.ExpandMore, null, tint = cs.onSurfaceVariant.copy(alpha = 0.5f), modifier = Modifier.size(14.dp)) + } + DropdownMenu(expanded = showSamplingPopup, onDismissRequest = { showSamplingPopup = false }) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp).width(240.dp)) { + SamplingRow( + label = stringResource(R.string.sampling_temperature), + param = samplingPrefs.temperature, min = 0f, max = 2f, steps = 200, + onUpdate = { onSamplingChange(samplingPrefs.copy(temperature = it)) }, + ) + Spacer(Modifier.height(12.dp)) + SamplingRow( + label = stringResource(R.string.sampling_top_p), + param = samplingPrefs.topP, min = 0f, max = 1f, steps = 100, + onUpdate = { onSamplingChange(samplingPrefs.copy(topP = it)) }, + ) + Spacer(Modifier.height(12.dp)) + SamplingRow( + label = stringResource(R.string.sampling_top_k), + param = samplingPrefs.topK, min = 1f, max = 100f, steps = 99, + onUpdate = { onSamplingChange(samplingPrefs.copy(topK = it)) }, + ) + } + } + } + + // Mode pills (Bild, Suche, Video, Audio) + chatModes.filter { it.labelRes != R.string.mode_standard && it.labelRes != R.string.mode_sampling }.forEach { mode -> val label = stringResource(mode.labelRes) val active = mode.labelRes == activeMode - val backgroundFill = if (active) { - accent.primary.copy(alpha = if (isDark) 0.18f else 0.14f) - } else { - if (isDark) cs.surface.copy(alpha = 0.35f) else cs.surface.copy(alpha = 0.55f) - } - val borderColor = if (active) { - accent.primary.copy(alpha = 0.4f) - } else { - if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f) - } + val backgroundFill = if (active) accent.primary.copy(alpha = if (isDark) 0.18f else 0.14f) else inactiveBg + val borderColor = if (active) accent.primary.copy(alpha = 0.4f) else inactiveBorder Row( Modifier @@ -185,6 +276,28 @@ fun ModePillsRow(activeMode: Int?, onToggle: (Int) -> Unit, modifier: Modifier = } } +@Composable +private fun SamplingRow(label: String, param: SamplingParam, min: Float, max: Float, steps: Int, onUpdate: (SamplingParam) -> Unit) { + val cs = MaterialTheme.colorScheme + val accent = LocalKaizenAccent.current + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = param.enabled, onCheckedChange = { onUpdate(param.copy(enabled = it)) }, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(label, color = cs.onBackground, fontSize = 13.sp, modifier = Modifier.weight(1f)) + Text( + if (max > 2f) param.value.toInt().toString() else "%.2f".format(param.value), + color = cs.onSurfaceVariant, fontSize = 12.sp, + ) + } + Slider( + value = param.value, onValueChange = { onUpdate(param.copy(value = it)) }, + valueRange = min..max, steps = steps, + enabled = param.enabled, + colors = SliderDefaults.colors(thumbColor = accent.primary, activeTrackColor = accent.primary), + modifier = Modifier.fillMaxWidth(), + ) +} + @Composable fun MessageRow(message: Message) { val cs = MaterialTheme.colorScheme diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt index e20b3c2..f988668 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt @@ -18,6 +18,25 @@ import dev.kaizen.app.net.ToolEvent enum class Role { User, Assistant } +enum class ReasoningPreset(val label: Int, val effort: String?, val throughput: Boolean) { + Instant(R.string.reasoning_instant, "none", true), + None(R.string.reasoning_none, "none", false), + Minimal(R.string.reasoning_minimal, "minimal", false), + Low(R.string.reasoning_low, "low", false), + Standard(R.string.reasoning_standard, null, false), + Medium(R.string.reasoning_medium, "medium", false), + High(R.string.reasoning_high, "high", false), + XHigh(R.string.reasoning_xhigh, "xhigh", false), +} + +data class SamplingParam(val enabled: Boolean, val value: Float) + +data class SamplingPrefs( + val temperature: SamplingParam = SamplingParam(false, 1f), + val topP: SamplingParam = SamplingParam(false, 1f), + val topK: SamplingParam = SamplingParam(false, 40f), +) + data class Message( val id: Long, val role: Role, 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 aa64831..5fa7d72 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -98,6 +98,8 @@ fun ChatScreen( var isStreaming by remember { mutableStateOf(false) } var nextId by remember { mutableStateOf(0L) } var activeMode by remember { mutableStateOf(null) } + var reasoningPreset by remember { mutableStateOf(ReasoningPreset.Standard) } + var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) } // Navigation State var currentScreen by remember { mutableStateOf(AppScreen.Chat) } @@ -337,9 +339,14 @@ fun ChatScreen( } else null KaizenApi.chat( - cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed, + cfg.baseUrl, cfg.token, cfg.model, history, + reasoningEffort = reasoningPreset.effort, + preferThroughput = reasoningPreset.throughput, attachments = uploadedAtts.takeIf { it.isNotEmpty() }, webSearch = activeMode == R.string.mode_search, + temperature = samplingPrefs.temperature.takeIf { it.enabled }?.value, + topP = samplingPrefs.topP.takeIf { it.enabled }?.value, + topK = samplingPrefs.topK.takeIf { it.enabled }?.value?.toInt(), ).collect { state -> val idx = messages.indexOfLast { it.id == assistantId } if (idx < 0) return@collect @@ -627,6 +634,10 @@ fun ChatScreen( activeMode = if (activeMode == mode) null else mode haptics.tick() }, + reasoningPreset = reasoningPreset, + onReasoningChange = { reasoningPreset = it; haptics.tick() }, + samplingPrefs = samplingPrefs, + onSamplingChange = { samplingPrefs = it }, ) Spacer(Modifier.height(10.dp)) ChatInput( 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 4e88cdc..71739ab 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -36,6 +36,9 @@ import java.util.concurrent.TimeUnit val preferThroughput: Boolean? = null, val attachments: List? = null, val webSearch: Boolean? = null, + val temperature: Float? = null, + val topP: Float? = null, + val topK: Int? = null, ) /** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */ @@ -439,18 +442,25 @@ object KaizenApi { token: String, model: String, messages: List, - speed: ChatSpeed = ChatSpeed.Normal, + reasoningEffort: String? = null, + preferThroughput: Boolean = false, attachments: List? = null, webSearch: Boolean = false, + temperature: Float? = null, + topP: Float? = null, + topK: Int? = null, ): Flow = flow { val payload = json.encodeToString( ChatRequest( model = model, messages = messages, - reasoningEffort = speed.reasoningEffort, - preferThroughput = if (speed.preferThroughput) true else null, + reasoningEffort = reasoningEffort, + preferThroughput = if (preferThroughput) true else null, attachments = attachments?.takeIf { it.isNotEmpty() }, webSearch = if (webSearch) true else null, + temperature = temperature, + topP = topP, + topK = topK, ), ).toRequestBody(jsonMedia) val req = Request.Builder() diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 4c91030..1e9dcb1 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -74,6 +74,21 @@ Confirm Save + + Instant + Off + Minimal + Low + Standard + Medium + High + Maximum + + + Temperature + Top-P + Top-K + Unlock Chat Confirm your identity diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f5d6f9..fef7101 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,6 +76,21 @@ Bestätigen Speichern + + Sofort + Aus + Minimal + Niedrig + Standard + Mittel + Hoch + Maximal + + + Temperatur + Top-P + Top-K + Chat entsperren Bestätige deine Identität