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:
1 parent
4b5ed6e398
commit
1aac22bfc9
10 files changed
+291
-52
No files matched your search
@@ -251,8 +251,18 @@ data class SessionSummary(
|
||||
val transcriptFile: FileOnMachine?,
|
||||
)
|
||||
|
||||
/** A file somewhere the explorer can be pointed at: which machine, and the path on it. */
|
||||
data class FileOnMachine(val machine: String, val machineName: String, val path: String)
|
||||
/**
|
||||
* A file somewhere the explorer can be pointed at: which machine, and the path on it.
|
||||
*
|
||||
* [bytes] is how big it is, and null is "the server did not say" rather than zero: a file of no
|
||||
* bytes and a file nobody could measure are different answers.
|
||||
*/
|
||||
data class FileOnMachine(
|
||||
val machine: String,
|
||||
val machineName: String,
|
||||
val path: String,
|
||||
val bytes: Long? = null,
|
||||
)
|
||||
|
||||
private fun parseSession(session: JSONObject) =
|
||||
SessionSummary(
|
||||
@@ -292,6 +302,7 @@ private fun parseSession(session: JSONObject) =
|
||||
machine = file.getString("machine"),
|
||||
machineName = file.getString("machineName"),
|
||||
path = file.getString("path"),
|
||||
bytes = if (file.has("bytes")) file.getLong("bytes") else null,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -57,12 +57,10 @@ fun LabelledField(
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
) {
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 2.dp),
|
||||
)
|
||||
// Body size in the ordinary text colour, which is what a setting's label is where the
|
||||
// control beside it is a switch or a picker. Smaller and greyer on the ones that are
|
||||
// fields reads as two ranks of setting where there is one.
|
||||
Text(label, modifier = Modifier.padding(bottom = 2.dp))
|
||||
hint?.let {
|
||||
Text(
|
||||
it,
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2506,6 +2506,7 @@ fun SessionScreen(
|
||||
params = params,
|
||||
onParamsChanged = { params = it },
|
||||
cachedBytes = cachedBytes,
|
||||
transcriptBytes = summary.transcriptFile?.bytes,
|
||||
// The purge finishes before the epoch moves, because the relaunched opening effect
|
||||
// reads the same directory and would otherwise draw what is about to be deleted. The
|
||||
// epoch is what makes the rest a cold open.
|
||||
|
||||
@@ -116,6 +116,11 @@ fun SessionSettingsScreen(
|
||||
* the Reload row below, which is what would discard it.
|
||||
*/
|
||||
cachedBytes: Long?,
|
||||
/**
|
||||
* How big the record on the server is, or null where it did not say. The other half of the pair
|
||||
* beside it: what the conversation costs there, against what this phone is holding of it.
|
||||
*/
|
||||
transcriptBytes: Long?,
|
||||
onReload: () -> Unit,
|
||||
/**
|
||||
* Opens the transcript file itself in the explorer. Null from a server that does not say where
|
||||
@@ -569,22 +574,37 @@ fun SessionSettingsScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Transcript", modifier = Modifier.weight(1f))
|
||||
// The size is what the button discards, and the unknown state is drawn rather
|
||||
// than guessed: a spinner while the directory is being measured, and words when
|
||||
// there is nothing there, because "nothing cached" and "0 B" read as different
|
||||
// claims.
|
||||
// What the conversation costs on the server, and then what Reload would
|
||||
// discard here -- one line, so the two sizes read as a pair. A server that
|
||||
// did not measure its file leaves its half out rather than saying zero.
|
||||
val onServer = transcriptBytes?.let { humanSize(it) ?: "0 B" }
|
||||
// The unknown state is drawn rather than guessed: a spinner while the cache
|
||||
// is being measured, and words when there is nothing in it, because "nothing
|
||||
// cached" and "0 B" read as different claims.
|
||||
when {
|
||||
cachedBytes == null ->
|
||||
cachedBytes == null -> {
|
||||
onServer?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp).height(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
else ->
|
||||
}
|
||||
else -> {
|
||||
val cached =
|
||||
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached"
|
||||
Text(
|
||||
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
|
||||
onServer?.let { "$it · $cached" } ?: cached,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both on a line of their own under what they act on, rather than crowded against
|
||||
|
||||
Reference in new issue
Block a user