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.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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue