Say a transcript's size, name a model by something, and ask before a reload

Five things asked for on the phone, and one trap behind the first of them.

A llama prompt carries `<__media__>` where a picture was, and llama.cpp
pairs each marker with a decoded image when it tokenizes -- so a marker in
words nobody attached a picture to fails the turn, and then fails every
later one, since the conversation is folded out of a transcript that holds
it for good. A model saying the marker back is enough to do it. Every
message with words in it now goes through `without_marker`.

A model's `general.name` is filled in by whatever converted the file, and
`convert_hf_to_gguf.py` fills it from the directory it converted: Prism ML's
Bonsai publishes `general.name = "Hf"`, which is unique and so passed the
label cascade and told a reader nothing. A name is now used only where it
shares a word with the repo or the file it came from; one that does not
drops to the file name.

The model settings dialog and the server card said what a save would cost in
a paragraph under the control, read after the decision if at all. Both ask
instead, in the shape the rest of that screen already uses for Stop and
Delete -- and the dialog's question is asked over the edits, so Cancel comes
back to them.

A field's label was `labelMedium` in the variant colour while every setting
beside it was body text, which on one form read as two ranks of setting.

`GET /sessions/{id}`'s `transcriptFile` now carries the file's size, and the
settings screen draws it beside what this phone has cached.

Checked on the emulator against the sandbox: the labels line up, the row
says "14 kB · 14 kB cached", and both confirmations appear over a loaded
Qwen3-0.6B. `cargo test` 275 passed, clippy and fmt clean, lint clean.
This commit is contained in:
iris-ai committed 2026-09-21 11:59:26 -04:00
1 parent 4b5ed6e398
commit 1aac22bfc9
10 files changed
+291 -52

No files matched your search

@@ -180,12 +180,6 @@ fun ProviderScreen(
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))
}
}
@@ -324,6 +318,7 @@ private fun ServerCard(
// half-typed is visibly not saved rather than quietly either way.
val saved = maxLoaded?.toString().orEmpty()
var typed by remember(saved) { mutableStateOf(saved) }
var confirming by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text("Model server", style = MaterialTheme.typography.titleSmall)
@@ -354,20 +349,55 @@ private fun ServerCard(
Spacer(Modifier.weight(1f))
TextButton(
enabled = enabled && typed != saved,
onClick = { onMaxLoaded(typed.toIntOrNull()) },
// Saving this while the server is up changes nothing until it comes down
// again, which is asked rather than written underneath -- see [RestartDialog].
onClick = {
if (server.running) confirming = true else onMaxLoaded(typed.toIntOrNull())
},
) {
Text("Save")
}
}
if (typed != saved) {
Text(
"Read when this server next starts.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (confirming) {
RestartDialog(
title = "Save for the next start?",
text =
"This server is running, and how many models it keeps loaded was decided when it " +
"started. Saving now changes what it does the next time it starts -- stop it " +
"here to have that be now.",
onConfirm = {
confirming = false
onMaxLoaded(typed.toIntOrNull())
},
onDismiss = { confirming = false },
)
}
}
/**
* What a setting read at load time costs, asked before it is written.
*
* One shape for both of the questions on this screen — a model's settings and the server's —
* because they are the same question: this is read when something starts again, and here is what
* starting again costs. A paragraph under the control said the same thing and was read after the
* decision, if at all.
*/
@Composable
private fun RestartDialog(
title: String,
text: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(text) },
confirmButton = { TextButton(onClick = onConfirm) { Text("Save") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
@@ -452,6 +482,10 @@ private fun ModelSettingsDialog(
onSave: (Map<String, String>) -> Unit,
) {
var params by remember(model.id) { mutableStateOf(model.settings) }
// Asked over this dialog rather than instead of it, so Cancel comes back to the edits rather
// than throwing them away.
var confirming by remember(model.id) { mutableStateOf(false) }
val loaded = model.status == "loaded" || model.status == "sleeping"
AlertDialog(
onDismissRequest = onDismiss,
// Every control here is a number, so the keyboard is up for most of this dialog's life --
@@ -463,29 +497,35 @@ private fun ModelSettingsDialog(
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.
// Every one of these is read at load time, which is what Save stops to say --
// marking each control "on restart" would repeat it six times over.
warnAboutRestart = false,
)
}
},
confirmButton = { TextButton(onClick = { onSave(params) }) { Text("Save") } },
confirmButton = {
TextButton(onClick = { if (loaded) confirming = true else onSave(params) }) {
Text("Save")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
if (confirming) {
RestartDialog(
title = "Unload ${model.label}?",
text =
"It is in memory now, and these are read when it is loaded. Saving takes it out " +
"of memory; the sessions using it load it again with these settings on their " +
"next message.",
onConfirm = {
confirming = false
onSave(params)
},
onDismiss = { confirming = false },
)
}
}