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.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

View file

@ -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,

View file

@ -98,6 +98,8 @@ fun ChatScreen(
var isStreaming by remember { mutableStateOf(false) }
var nextId by remember { mutableStateOf(0L) }
var activeMode by remember { mutableStateOf<Int?>(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(

View file

@ -36,6 +36,9 @@ import java.util.concurrent.TimeUnit
val preferThroughput: Boolean? = null,
val attachments: List<Attachment>? = 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<WireMessage>,
speed: ChatSpeed = ChatSpeed.Normal,
reasoningEffort: String? = null,
preferThroughput: Boolean = false,
attachments: List<Attachment>? = null,
webSearch: Boolean = false,
temperature: Float? = null,
topP: Float? = null,
topK: Int? = null,
): Flow<StreamState> = 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()

View file

@ -74,6 +74,21 @@
<string name="sidebar_confirm">Confirm</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 -->
<string name="biometric_title">Unlock Chat</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_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 -->
<string name="biometric_title">Chat entsperren</string>
<string name="biometric_subtitle">Bestätige deine Identität</string>