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:
parent
30d3f2807d
commit
f6dc5db363
6 changed files with 199 additions and 16 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue