reasoning presets + sampling controls (functional, not mock)

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).
This commit is contained in:
Bruno Deanoz 2026-06-22 00:19:14 +02:00
parent 30d3f2807d
commit f6dc5db363
6 changed files with 199 additions and 16 deletions

View file

@ -7,6 +7,11 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween 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.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border 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.material.icons.rounded.VideoFile
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue 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.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -142,11 +151,25 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
} }
@Composable @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 cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current 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( Row(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
@ -154,19 +177,87 @@ fun ModePillsRow(activeMode: Int?, onToggle: (Int) -> Unit, modifier: Modifier =
.padding(horizontal = 14.dp), .padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.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 label = stringResource(mode.labelRes)
val active = mode.labelRes == activeMode val active = mode.labelRes == activeMode
val backgroundFill = if (active) { val backgroundFill = if (active) accent.primary.copy(alpha = if (isDark) 0.18f else 0.14f) else inactiveBg
accent.primary.copy(alpha = if (isDark) 0.18f else 0.14f) val borderColor = if (active) accent.primary.copy(alpha = 0.4f) else inactiveBorder
} 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)
}
Row( Row(
Modifier 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 @Composable
fun MessageRow(message: Message) { fun MessageRow(message: Message) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme

View file

@ -18,6 +18,25 @@ import dev.kaizen.app.net.ToolEvent
enum class Role { User, Assistant } 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( data class Message(
val id: Long, val id: Long,
val role: Role, val role: Role,

View file

@ -98,6 +98,8 @@ fun ChatScreen(
var isStreaming by remember { mutableStateOf(false) } var isStreaming by remember { mutableStateOf(false) }
var nextId by remember { mutableStateOf(0L) } var nextId by remember { mutableStateOf(0L) }
var activeMode by remember { mutableStateOf<Int?>(null) } var activeMode by remember { mutableStateOf<Int?>(null) }
var reasoningPreset by remember { mutableStateOf(ReasoningPreset.Standard) }
var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) }
// Navigation State // Navigation State
var currentScreen by remember { mutableStateOf(AppScreen.Chat) } var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
@ -337,9 +339,14 @@ fun ChatScreen(
} else null } else null
KaizenApi.chat( 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() }, attachments = uploadedAtts.takeIf { it.isNotEmpty() },
webSearch = activeMode == R.string.mode_search, 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 -> ).collect { state ->
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect if (idx < 0) return@collect
@ -627,6 +634,10 @@ fun ChatScreen(
activeMode = if (activeMode == mode) null else mode activeMode = if (activeMode == mode) null else mode
haptics.tick() haptics.tick()
}, },
reasoningPreset = reasoningPreset,
onReasoningChange = { reasoningPreset = it; haptics.tick() },
samplingPrefs = samplingPrefs,
onSamplingChange = { samplingPrefs = it },
) )
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
ChatInput( ChatInput(

View file

@ -36,6 +36,9 @@ import java.util.concurrent.TimeUnit
val preferThroughput: Boolean? = null, val preferThroughput: Boolean? = null,
val attachments: List<Attachment>? = null, val attachments: List<Attachment>? = null,
val webSearch: Boolean? = 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. */ /** 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, token: String,
model: String, model: String,
messages: List<WireMessage>, messages: List<WireMessage>,
speed: ChatSpeed = ChatSpeed.Normal, reasoningEffort: String? = null,
preferThroughput: Boolean = false,
attachments: List<Attachment>? = null, attachments: List<Attachment>? = null,
webSearch: Boolean = false, webSearch: Boolean = false,
temperature: Float? = null,
topP: Float? = null,
topK: Int? = null,
): Flow<StreamState> = flow { ): Flow<StreamState> = flow {
val payload = json.encodeToString( val payload = json.encodeToString(
ChatRequest( ChatRequest(
model = model, model = model,
messages = messages, messages = messages,
reasoningEffort = speed.reasoningEffort, reasoningEffort = reasoningEffort,
preferThroughput = if (speed.preferThroughput) true else null, preferThroughput = if (preferThroughput) true else null,
attachments = attachments?.takeIf { it.isNotEmpty() }, attachments = attachments?.takeIf { it.isNotEmpty() },
webSearch = if (webSearch) true else null, webSearch = if (webSearch) true else null,
temperature = temperature,
topP = topP,
topK = topK,
), ),
).toRequestBody(jsonMedia) ).toRequestBody(jsonMedia)
val req = Request.Builder() val req = Request.Builder()

View file

@ -74,6 +74,21 @@
<string name="sidebar_confirm">Confirm</string> <string name="sidebar_confirm">Confirm</string>
<string name="sidebar_save">Save</string> <string name="sidebar_save">Save</string>
<!-- Reasoning presets -->
<string name="reasoning_instant">Instant</string>
<string name="reasoning_none">Off</string>
<string name="reasoning_minimal">Minimal</string>
<string name="reasoning_low">Low</string>
<string name="reasoning_standard">Standard</string>
<string name="reasoning_medium">Medium</string>
<string name="reasoning_high">High</string>
<string name="reasoning_xhigh">Maximum</string>
<!-- Sampling -->
<string name="sampling_temperature">Temperature</string>
<string name="sampling_top_p">Top-P</string>
<string name="sampling_top_k">Top-K</string>
<!-- Biometric unlock --> <!-- Biometric unlock -->
<string name="biometric_title">Unlock Chat</string> <string name="biometric_title">Unlock Chat</string>
<string name="biometric_subtitle">Confirm your identity</string> <string name="biometric_subtitle">Confirm your identity</string>

View file

@ -76,6 +76,21 @@
<string name="sidebar_confirm">Bestätigen</string> <string name="sidebar_confirm">Bestätigen</string>
<string name="sidebar_save">Speichern</string> <string name="sidebar_save">Speichern</string>
<!-- Reasoning presets -->
<string name="reasoning_instant">Sofort</string>
<string name="reasoning_none">Aus</string>
<string name="reasoning_minimal">Minimal</string>
<string name="reasoning_low">Niedrig</string>
<string name="reasoning_standard">Standard</string>
<string name="reasoning_medium">Mittel</string>
<string name="reasoning_high">Hoch</string>
<string name="reasoning_xhigh">Maximal</string>
<!-- Sampling -->
<string name="sampling_temperature">Temperatur</string>
<string name="sampling_top_p">Top-P</string>
<string name="sampling_top_k">Top-K</string>
<!-- Biometric unlock --> <!-- Biometric unlock -->
<string name="biometric_title">Chat entsperren</string> <string name="biometric_title">Chat entsperren</string>
<string name="biometric_subtitle">Bestätige deine Identität</string> <string name="biometric_subtitle">Bestätige deine Identität</string>