Serve a machine's models from one shared llama-server

A llama.cpp session had its own `llama-server`: two sessions on one model
held two copies of it in memory, a model change bought a load only that
session benefited from, and the process was a session's to end. A machine's
models are now served by one `llama-server` in **router mode** -- no `-m`,
a preset file naming models and their flags, a child server per model asked
for, and each request routed by its `model` field. So one server per model
with that model's own settings is what a machine runs, while this backend
has one process, one port and one record per machine to keep track of.

The record is the mechanism every other driver already uses, so a restart
adopts it; a session records the same pid in its own directory as
`Detail::Shared`, and `process::signal` refuses to signal one of those --
which is what keeps stopping, deleting or cleaning up after one session
from unloading a model every other session is using. Nothing stops a router
on its own. That is deliberate (a loaded model is minutes of disk) and it is
why the machines tab now has a card per provider that opens its own screen:
how each model is loaded, how many stay in memory, Unload, and Stop.

How a model is *loaded* therefore belongs to the model on its machine rather
than to a session -- context size, GPU layers, threads, slots, speculative
decoding -- written into the preset as llama-server's own argument names.
Saving them re-reads that file, which unloads the model; that is the change
taking effect, and the dialog says so before you save. What stays a
session's is everything that rides on a request, including which tools it
offers: the router hosts one set for the machine and the choice is a filter
applied here, so it costs no reload (2,181 tokens of prompt with all seven,
698 with none).

Verified end to end against the scratch backend and the emulator: two
sessions sharing one loaded model with one child process, a second session
joining it with a 26ms prefill, a backend restart adopting the router and
answering with the prompt cache intact, the same over ssh to this VM, a
model's settings reaching the running server, Unload, and Stop leaving every
session `exited` with no error line.
This commit is contained in:
iris-ai committed 2026-09-19 17:37:31 -04:00
1 parent 74cda485e5
commit 8c323fc7a9
19 files changed
+2601 -511

No files matched your search

@@ -403,20 +403,20 @@ private fun parseProvider(provider: JSONObject): Provider {
models = provider.optJSONArray("models")?.strings().orEmpty(),
permissionModes = provider.optJSONArray("permissionModes")?.strings().orEmpty(),
defaultPermissionMode = provider.optString("defaultPermissionMode").ifEmpty { null },
params =
provider.optJSONArray("params")?.mapObjects { spec ->
ParamSpec(
key = spec.getString("key"),
label = spec.getString("label"),
unset = spec.getString("unset"),
kind = spec.getString("kind"),
options = spec.optJSONArray("options")?.strings().orEmpty(),
restart = spec.optBoolean("restart"),
)
} ?: emptyList(),
params = provider.optJSONArray("params")?.mapObjects(::parseParamSpec) ?: emptyList(),
)
}
private fun parseParamSpec(spec: JSONObject) =
ParamSpec(
key = spec.getString("key"),
label = spec.getString("label"),
unset = spec.getString("unset"),
kind = spec.getString("kind"),
options = spec.optJSONArray("options")?.strings().orEmpty(),
restart = spec.optBoolean("restart"),
)
private fun parseMachine(machine: JSONObject) =
Machine(
id = machine.getString("id"),
@@ -1473,6 +1473,146 @@ fun fetchProviderModels(
}
}
/**
* One provider on one machine: what it is, every model it offers with the settings that decide how
* that model is loaded, and what its shared server is holding.
*
* [server] is null for a provider that runs no server of its own — a CLI, or echo. That is not the
* same as a server that is down, and the screen says so differently.
*/
data class ProviderView(
val machine: String,
val name: String,
val kind: String,
val command: String?,
/** What each of this provider's models takes; empty for one that loads no models. */
val modelParams: List<ParamSpec>,
val maxLoaded: Int?,
val models: List<ProviderModel>,
val mcpServers: List<String>,
val server: ServerState?,
)
/**
* A model this provider offers, its saved settings, and what the server is doing with it.
*
* [status] is null where nothing was asked — a provider with no server, or one that is not running
* — rather than a guess at "unloaded", which is a fact about the server nobody checked.
*/
data class ProviderModel(
val id: String,
val label: String,
val settings: Map<String, String>,
val status: String?,
)
/** The shared server behind a provider: whether it is up, and where the backend reaches it. */
data class ServerState(val running: Boolean, val port: Int?)
private fun providerPath(machineId: String, provider: String, tail: String = "") =
"/machines/${machineId.urlEncoded()}/providers/${provider.urlEncoded()}$tail"
fun fetchProvider(settings: ServerSettings, machineId: String, provider: String): ProviderView =
requestFromServer(settings, providerPath(machineId, provider), readTimeoutMs = 40000) {
val body = it.jsonObject()
ProviderView(
machine = body.getString("machine"),
name = body.getString("name"),
kind = body.getString("kind"),
command = body.optString("command").ifEmpty { null },
modelParams = body.optJSONArray("modelParams")?.mapObjects(::parseParamSpec).orEmpty(),
maxLoaded = if (body.isNull("maxLoaded")) null else body.optInt("maxLoaded"),
models =
body
.optJSONArray("models")
?.mapObjects { model ->
ProviderModel(
id = model.getString("id"),
label = model.getString("label"),
settings = model.optJSONObject("settings").stringMap(),
status = model.optString("status").ifEmpty { null },
)
}
.orEmpty(),
mcpServers = body.optJSONArray("mcpServers")?.strings().orEmpty(),
server =
body.optJSONObject("server")?.let { server ->
ServerState(
running = server.optBoolean("running", false),
port = if (server.isNull("port")) null else server.optInt("port"),
)
},
)
}
/** How many models this provider's server keeps loaded at once; null for the default. */
fun setProviderSettings(
settings: ServerSettings,
machineId: String,
provider: String,
maxLoaded: Int?,
) {
requestFromServer(
settings,
providerPath(machineId, provider, "/settings"),
method = "POST",
jsonBody =
JSONObject().apply { if (maxLoaded != null) put("maxLoaded", maxLoaded) }.toString(),
) {}
}
/**
* How one model is loaded. Whole, like a session's params: what is absent is unset.
*
* Slow on purpose: the backend writes it where the machine's server reads it, which unloads the
* model if it was loaded — that is the change taking effect, not a side effect.
*/
fun setModelSettings(
settings: ServerSettings,
machineId: String,
provider: String,
model: String,
params: Map<String, String>,
) {
requestFromServer(
settings,
providerPath(machineId, provider, "/model-settings"),
method = "POST",
jsonBody =
JSONObject()
.put("model", model)
.put("params", JSONObject(params as Map<*, *>))
.toString(),
readTimeoutMs = 40000,
) {}
}
/** Ends the machine's shared server and everything it was holding. */
fun stopProviderServer(settings: ServerSettings, machineId: String, provider: String) {
requestFromServer(
settings,
providerPath(machineId, provider, "/stop"),
method = "POST",
readTimeoutMs = 40000,
) {}
}
/** Takes one model out of memory, leaving the server and every other model alone. */
fun unloadProviderModel(
settings: ServerSettings,
machineId: String,
provider: String,
model: String,
) {
requestFromServer(
settings,
providerPath(machineId, provider, "/unload"),
method = "POST",
jsonBody = JSONObject().put("model", model).toString(),
readTimeoutMs = 40000,
) {}
}
fun fetchModels(settings: ServerSettings): Models =
requestFromServer(settings, "/models") { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText())
@@ -56,6 +56,16 @@ private sealed class Screen {
data object Spawn : Screen()
/**
* One provider on one machine: its settings, and what its shared server is holding.
*
* A step down from the machines tab rather than a tab of its own, because it is about one
* machine rather than about the backend. Addressed by ids and names rather than by the
* [Provider] it was tapped from: what it shows is fetched, and a stale copy of a card would be
* a second version of the same truth.
*/
data class ProviderSettings(val machineId: String, val provider: String) : Screen()
data object Settings : Screen()
}
@@ -199,6 +209,9 @@ fun AppRoot(
screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
)
}
is Screen.Session ->
@@ -268,6 +281,15 @@ fun AppRoot(
}
}
}
is Screen.ProviderSettings ->
Box(Modifier.imePadding()) {
ProviderScreen(
settings = current,
machineId = here.machineId,
provider = here.provider,
onBack = goToMain,
)
}
is Screen.Spawn ->
Box(Modifier.imePadding()) {
SpawnScreen(
@@ -1,5 +1,6 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -24,6 +26,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -37,7 +41,12 @@ import kotlinx.coroutines.withContext
* which is what keeps the enrolled token from being able to introduce commands.
*/
@Composable
fun MachinesScreen(settings: ServerSettings, reloadToken: Int) {
fun MachinesScreen(
settings: ServerSettings,
reloadToken: Int,
/** Opens one provider on one machine -- its settings, and what its server is holding. */
onProvider: (String, String) -> Unit,
) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
var adding by remember { mutableStateOf(false) }
@@ -108,6 +117,7 @@ fun MachinesScreen(settings: ServerSettings, reloadToken: Int) {
},
onDelete = { confirmingDelete = machine },
onSignIn = { provider -> signingIn = machine to provider },
onProvider = { provider -> onProvider(machine.id, provider.name) },
)
}
}
@@ -214,6 +224,7 @@ private fun MachineCard(
onRediscover: () -> Unit,
onDelete: () -> Unit,
onSignIn: (Provider) -> Unit,
onProvider: (Provider) -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
@@ -233,14 +244,34 @@ private fun MachineCard(
)
} else {
machine.providers.forEach { provider ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
// A card of its own rather than a line of text: a provider is where the
// settings that belong to *this machine* live -- how each of its models is
// loaded, and the server holding them -- and those had nowhere to be until
// one llama-server came to serve every session on a machine.
Card(
Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable {
onProvider(provider)
},
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
) {
Text(provider.name, style = MaterialTheme.typography.bodySmall)
if (provider.kind == "claude_cli") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
) {
Text(provider.name, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.weight(1f))
TextButton(onClick = { onSignIn(provider) }) { Text("Sign in") }
if (provider.kind == "claude_cli") {
TextButton(onClick = { onSignIn(provider) }) { Text("Sign in") }
}
Chevron(
Pointing.Right,
Modifier.padding(start = 4.dp).semantics {
contentDescription = "Settings for ${provider.name}"
},
)
}
}
}
@@ -51,6 +51,8 @@ fun MainScreen(
onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit,
/** One machine's provider, opened from the machines tab. */
onProvider: (String, String) -> Unit,
) {
var tab by remember { mutableStateOf(MainTab.Sessions) }
var refreshToken by remember { mutableIntStateOf(0) }
@@ -144,7 +146,8 @@ fun MainScreen(
MainTab.Import ->
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
MainTab.Machines -> MachinesScreen(settings = settings, reloadToken = token)
MainTab.Machines ->
MachinesScreen(settings = settings, reloadToken = token, onProvider = onProvider)
}
}
}
@@ -0,0 +1,405 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* One provider on one machine: what it is, what its shared server is holding, and how each of its
* models is loaded.
*
* This is where a setting that belongs to a *machine* lives, as opposed to one that belongs to a
* session. The two were one list until llama.cpp sessions came to share one server per machine: how
* a model is loaded stopped being anything a single session could decide, because one copy of it in
* memory is what several sessions are talking to.
*
* It is also the only place a loaded model is taken out of memory. Nothing does that on its own —
* closing a session leaves the model loaded on purpose, since the next one to want it would
* otherwise pay the load again — so the memory is freed here, where what it costs everybody is
* visible.
*/
@Composable
fun ProviderScreen(
settings: ServerSettings,
machineId: String,
provider: String,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<ProviderView>>(LoadState.Loading) }
var reload by remember { mutableIntStateOf(0) }
var editing by remember { mutableStateOf<ProviderModel?>(null) }
var confirmingStop by remember { mutableStateOf(false) }
// What is being done to the server or to one of its models, in a word, and what went wrong
// when it did. Both here rather than per row: these act on the whole machine.
var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(reload) {
state =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchProvider(settings, machineId, provider))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// Say what is happening, do it, say what went wrong, refetch: every action on this screen
// changes what it is showing.
val act = { what: String, action: suspend () -> Unit ->
scope.launch {
busy = what
actionError =
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
busy = null
reload++
}
Unit
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = onBack) { Text("Back") }
}
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> {
val view = current.value
Text(view.name, style = MaterialTheme.typography.titleMedium)
Text(
"on ${view.machine}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
view.command?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(12.dp))
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
busy?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
Text(it, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
}
LazyColumn(Modifier.fillMaxSize()) {
view.server?.let { server ->
item("server") {
ServerCard(
server = server,
maxLoaded = view.maxLoaded,
enabled = busy == null,
onStop = { confirmingStop = true },
onMaxLoaded = { chosen ->
act("Saving…") {
setProviderSettings(
settings,
machineId,
provider,
chosen,
)
}
},
)
Spacer(Modifier.height(12.dp))
}
}
if (view.models.isNotEmpty() && view.modelParams.isNotEmpty()) {
item("models-heading") {
Text("Models", style = MaterialTheme.typography.titleSmall)
Text(
"How a model is loaded belongs to the machine, not to a session: " +
"one copy of it in memory answers every session using it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
}
}
uniqueItems(view.models, key = { it.id }) { model ->
ModelCard(
model = model,
specs = view.modelParams,
// Tapping opens the settings; a provider whose models take none has
// nothing to open, so the row is not a control.
onEdit =
if (view.modelParams.isEmpty()) null else ({ editing = model }),
onUnload =
if (model.status == "loaded" || model.status == "sleeping") {
{
act("Unloading ${model.label}") {
unloadProviderModel(
settings,
machineId,
provider,
model.id,
)
}
}
} else null,
enabled = busy == null,
)
}
if (view.mcpServers.isNotEmpty()) {
item("mcp") {
Spacer(Modifier.height(12.dp))
Text("Tool servers", style = MaterialTheme.typography.titleSmall)
Text(
view.mcpServers.joinToString(", ") +
" — configured on the backend, in its config file.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
editing?.let { model ->
val view = (state as? LoadState.Loaded)?.value
ModelSettingsDialog(
model = model,
specs = view?.modelParams.orEmpty(),
onDismiss = { editing = null },
onSave = { params ->
editing = null
act("Saving ${model.label}") {
setModelSettings(settings, machineId, provider, model.id, params)
}
},
)
}
if (confirmingStop) {
AlertDialog(
onDismissRequest = { confirmingStop = false },
title = { Text("Stop this server?") },
text = {
// Said plainly rather than hidden: this is the only thing that frees the memory,
// and what it costs is that every session on this machine reloads its model.
Text(
"Every model it is holding is unloaded. Sessions using it will show as " +
"exited, and the next message to one loads its model again — which is " +
"the slow part, not the sending."
)
},
confirmButton = {
TextButton(
onClick = {
confirmingStop = false
act("Stopping…") { stopProviderServer(settings, machineId, provider) }
}
) {
Text("Stop")
}
},
dismissButton = { TextButton(onClick = { confirmingStop = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun ServerCard(
server: ServerState,
maxLoaded: Int?,
enabled: Boolean,
onStop: () -> Unit,
onMaxLoaded: (Int?) -> Unit,
) {
// The saved value is what this starts at and what Save is compared against, so a field left
// half-typed is visibly not saved rather than quietly either way.
val saved = maxLoaded?.toString().orEmpty()
var typed by remember(saved) { mutableStateOf(saved) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text("Model server", style = MaterialTheme.typography.titleSmall)
Text(
if (server.running) {
"Running" + (server.port?.let { ", reached on port $it" } ?: "")
} else {
// Not a fault: nothing is loaded because nothing has asked. Saying it in
// words rather than colouring the row, since "stopped" and "we could not
// ask" would otherwise look the same.
"Not running. A session starts it when it needs a model."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = typed,
onValueChange = { typed = it.filter(Char::isDigit) },
label = { Text("Models loaded at once") },
placeholder = { Text("one -- a second model replaces the first") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
// Shown whether or not it is running, and disabled when there is nothing to stop:
// a button that comes and goes makes its own absence the message.
TextButton(enabled = enabled && server.running, onClick = onStop) { Text("Stop") }
Spacer(Modifier.weight(1f))
TextButton(
enabled = enabled && typed != saved,
onClick = { onMaxLoaded(typed.toIntOrNull()) },
) {
Text("Save")
}
}
if (typed != saved) {
Text(
"Read when this server next starts.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ModelCard(
model: ProviderModel,
specs: List<ParamSpec>,
onEdit: (() -> Unit)?,
onUnload: (() -> Unit)?,
enabled: Boolean,
) {
Card(
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.then(if (onEdit != null && enabled) Modifier.clickable(onClick = onEdit) else Modifier)
) {
Column(Modifier.padding(12.dp)) {
Text(model.label, style = MaterialTheme.typography.bodyMedium)
// What the server is doing with it, in its own word. Absent means nobody could ask --
// the server is not running -- and the line is left out rather than guessed at.
model.status?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (model.settings.isNotEmpty()) {
Text(
// In the words the dialog uses, and in the order it draws them: a summary
// naming `contextSize` is a summary of a different screen than the one it
// sits under.
specs
.mapNotNull { spec ->
model.settings[spec.key]?.let { "${spec.label} $it" }
}
.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (onUnload != null) {
Row { TextButton(enabled = enabled, onClick = onUnload) { Text("Unload") } }
}
}
}
}
/**
* How one model is loaded.
*
* Saved on Save rather than as it is typed, unlike the session settings dialog: writing this
* unloads the model for everybody using it, which is not something to do once per keystroke.
*/
@Composable
private fun ModelSettingsDialog(
model: ProviderModel,
specs: List<ParamSpec>,
onDismiss: () -> Unit,
onSave: (Map<String, String>) -> Unit,
) {
var params by remember(model.id) { mutableStateOf(model.settings) }
AlertDialog(
onDismissRequest = onDismiss,
// Every control here is a number, so the keyboard is up for most of this dialog's life --
// and a dialog that keeps its own size under the keyboard puts Save off the bottom of the
// screen, where nothing on screen says it is there. Taking the insets ourselves is what
// lets `imePadding` shrink it instead.
properties = DialogProperties(decorFitsSystemWindows = false),
modifier = Modifier.imePadding(),
title = { Text(model.label) },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(
if (model.status == "loaded" || model.status == "sleeping") {
"This model is loaded. Saving takes it out of memory, and the sessions " +
"using it load it again with these settings on their next message."
} else {
"Read when this model is next loaded."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
ProviderParamFields(
specs = specs,
values = params,
onChange = { params = it },
// Every one of these is read at load time, and the sentence above already
// says when that is -- marking each control "on restart" would repeat it six
// times.
warnAboutRestart = false,
)
}
},
confirmButton = { TextButton(onClick = { onSave(params) }) { Text("Save") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}