From 1aac22bfc94c4dbc35b5c8c2bf57e7c49fc922c3 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Mon, 21 Sep 2026 11:59:26 -0400 Subject: [PATCH] Say a transcript's size, name a model by something, and ask before a reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 11 +++ .../src/main/kotlin/com/example/aiapp/Api.kt | 15 ++- .../main/kotlin/com/example/aiapp/Field.kt | 10 +- .../com/example/aiapp/ProviderScreen.kt | 98 +++++++++++++------ .../kotlin/com/example/aiapp/SessionScreen.kt | 1 + .../example/aiapp/SessionSettingsScreen.kt | 34 +++++-- server/src/models.rs | 95 +++++++++++++++++- server/src/routes.rs | 4 +- server/src/session/llama/mod.rs | 63 +++++++++++- server/src/session/mod.rs | 12 ++- 10 files changed, 291 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8229737..1feff83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -523,6 +523,17 @@ written, and the fold uses that same predicate to decide a reply is settled. `generate` now fails the turn for both -- a reply that stops early is not a reply, and the transcript keeps whatever arrived before it. +- **`<__media__>` in a llama prompt is a picture, wherever it came from.** + llama.cpp takes an image out of the request and leaves that marker in the + rendered text, then pairs each marker with a decoded image at tokenize time + -- so one in words nobody attached a picture to fails the turn with `number + of media markers in text (1) exceeds number of bitmaps (0)`, which reaches + the phone as "Failed to tokenize prompt". A model saying it back is enough, + and then *every* later message fails too, since the conversation is folded + out of a transcript that now holds it. `Message::new` and + `Message::from_user` take it out of anything that is text (`without_marker`), + which is every message with words in it. + - **A cancel flag is only as prompt as the next place somebody looks.** A llama turn waits on three things that look nowhere at all: a permission question, a tool call `llama-server` is running (a shell command there runs diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 43ea3b0..73a8fff 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -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, ) }, ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Field.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Field.kt index 8b457d0..ec9dd78 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Field.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Field.kt @@ -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, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt index 5fe972b..d9991fa 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt @@ -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) -> 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 }, + ) + } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index b2d4900..996db55 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt index deaf444..466584d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt @@ -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 diff --git a/server/src/models.rs b/server/src/models.rs index 11f59c8..77c521b 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -74,7 +74,10 @@ pub struct LocalModel { pub fn labels(models: &[LocalModel]) -> Vec { let rungs = |model: &LocalModel| { [ - model.name.clone(), + model + .name + .clone() + .filter(|name| names_the_file(name, model)), Some(model.file.trim_end_matches(".gguf").to_string()), Some(model.key.clone()), ] @@ -106,6 +109,35 @@ pub fn labels(models: &[LocalModel]) -> Vec { .collect() } +/// Whether a model's own `general.name` is a name for *this* model, or the +/// converter's working directory wearing the same field. +/// +/// The field is filled in by whatever produced the file, and +/// `convert_hf_to_gguf.py` fills it from the directory it converted -- so a +/// model converted out of a folder called `hf` publishes `general.name = "hf"`, +/// which is unique, passes the cascade above, and tells a reader nothing at +/// all. Prism ML's Bonsai is the one here (reported 2026-09-21, drawn as "hf" +/// in the model picker). +/// +/// The test is corroboration rather than a list of words to distrust: a real +/// name shares something with where the file came from, both being about the +/// same model, and a directory name picked up in passing does not. One word in +/// common is enough. A name that fails it drops to the next rung, which is the +/// file name the reader downloaded. +fn names_the_file(name: &str, model: &LocalModel) -> bool { + let words = |text: &str| -> Vec { + text.split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|word| word.len() > 1) + .map(str::to_ascii_lowercase) + .collect() + }; + let from = words(&model.repo); + let from_file = words(&model.file); + words(name) + .iter() + .any(|word| from.contains(word) || from_file.contains(word)) +} + /// What a download is doing, or did. /// /// Flat rather than a tagged enum carrying its message, because the phone @@ -702,3 +734,64 @@ pub fn urlencode(value: &str) -> String { }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + fn model(repo: &str, file: &str, name: Option<&str>) -> LocalModel { + LocalModel { + key: format!("{repo}/{file}"), + repo: repo.to_string(), + file: file.to_string(), + bytes: 1, + name: name.map(str::to_string), + } + } + + /// The cascade, and the one rung that is not simply "is it unique": a name + /// the file it came from says nothing about is the converter's working + /// directory rather than the model's name. + #[test] + fn a_model_is_labelled_by_the_first_thing_that_identifies_it() { + let models = [ + // Its own name, which is what a reader recognises. + model( + "Qwen/Qwen3-0.6B-GGUF", + "Qwen3-0.6B-Q8_0.gguf", + Some("Qwen3-0.6B"), + ), + // Two quantisations carry one name, so both fall to the file. + model("org/Big-GGUF", "Big-Q4_K_M.gguf", Some("Big")), + model("org/Big-GGUF", "Big-Q8_0.gguf", Some("Big")), + // `convert_hf_to_gguf.py` naming the directory it converted. + model("PrismML/Bonsai-GGUF", "Bonsai-1B-TQ1_0.gguf", Some("hf")), + // Nothing to go on but where it came from. + model("org/Quiet-GGUF", "weights.gguf", None), + ]; + assert_eq!( + labels(&models), + [ + "Qwen3-0.6B", + "Big-Q4_K_M", + "Big-Q8_0", + "Bonsai-1B-TQ1_0", + "weights", + ], + ); + } + + /// Two repos holding a file of the same name: the file rung collides as + /// well, and the key is what always terminates the cascade. + #[test] + fn a_file_name_two_repos_share_falls_through_to_the_key() { + let models = [ + model("one/GGUF", "model.gguf", None), + model("two/GGUF", "model.gguf", None), + ]; + assert_eq!( + labels(&models), + ["one/GGUF/model.gguf", "two/GGUF/model.gguf"] + ); + } +} diff --git a/server/src/routes.rs b/server/src/routes.rs index 77f8532..97a31c3 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -39,8 +39,8 @@ //! GET /sessions list (id, provider, title, model, status, last activity) //! GET /sessions/{id} one session, for refetching after a change. Its //! `transcriptFile` is where the record itself is -- -//! {machine, machineName, path} -- so the explorer can be -//! pointed at it +//! {machine, machineName, path, bytes?} -- so the explorer +//! can be pointed at it and its size said //! POST /sessions spawn {machine, provider, title?, model?, cwd?, params?} //! POST /sessions/order {sessions} -- the list in the order it is drawn in, //! as the reader dragged it; ids left out keep their diff --git a/server/src/session/llama/mod.rs b/server/src/session/llama/mod.rs index c12117b..c298c54 100644 --- a/server/src/session/llama/mod.rs +++ b/server/src/session/llama/mod.rs @@ -220,11 +220,46 @@ enum Content { Parts(Vec), } +/// llama.cpp's placeholder for a picture, as it appears *inside the prompt +/// text* once the server has taken the image out of the request. +/// +/// It is an in-band signal in the band that also carries what everybody typed, +/// so it has to be taken back out of anything that is merely words. +/// `mtmd_tokenize` splits the rendered prompt on it and pairs each occurrence +/// with a decoded image, so one nobody sent an image for fails the turn -- +/// `number of media markers in text (1) exceeds number of bitmaps (0)`, which +/// reaches the phone as "Failed to tokenize prompt" and then fails every later +/// message too, the words being in the conversation for good. A model that +/// says the marker back is enough to do it. +/// +/// llama.cpp's own default, which is what it uses unless started with +/// `--media-marker`; nothing here passes that, and the router writes the preset +/// its children are started from (see [`router`]). +const MEDIA_MARKER: &str = "<__media__>"; + +/// `text` with any [`MEDIA_MARKER`] removed, for content that is words rather +/// than a picture. +/// +/// Removed rather than escaped: there is nothing to escape it *to* -- the +/// marker is matched literally, and the only form llama.cpp will not read as a +/// picture is its absence. +fn without_marker(text: impl Into) -> String { + let text = text.into(); + if text.contains(MEDIA_MARKER) { + return text.replace(MEDIA_MARKER, ""); + } + text +} + impl Message { + /// Words, with anything llama.cpp would read as a picture taken out of + /// them -- see [`without_marker`]. Every message with text goes through + /// here, which is what makes that structural rather than a rule to + /// remember at each call. fn new(role: &str, content: impl Into) -> Self { Self { role: role.to_string(), - content: Content::Text(content.into()), + content: Content::Text(without_marker(content)), tool_calls: Vec::new(), tool_call_id: None, } @@ -239,7 +274,7 @@ impl Message { /// templates, and only the first has ever been tested by everything else /// here. fn from_user(text: impl Into, images: Vec) -> Self { - let text = text.into(); + let text = without_marker(text); if images.is_empty() { return Self::new("user", text); } @@ -3252,6 +3287,30 @@ mod tests { ); } + /// llama.cpp's own picture placeholder is not content, and words that + /// contain it are still words: left in, one of them fails the turn it is + /// sent in and every turn after it, because the conversation is folded out + /// of a transcript that now holds it for good. + #[test] + fn a_media_marker_in_words_never_reaches_the_model() { + assert_eq!( + Message::new("user", format!("what is {MEDIA_MARKER} this?")).content, + Content::Text("what is this?".to_string()), + ); + // A tool's output is the other text somebody else wrote. + assert_eq!( + Message::result_of("call_1", MEDIA_MARKER).content, + Content::Text(String::new()), + ); + // Beside a real picture, which is the part that says there is one. + let image = + json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,UE5H"}}); + assert_eq!( + Message::from_user(MEDIA_MARKER, vec![image.clone()]).content, + Content::Parts(vec![image]), + ); + } + /// A file that cannot be read is left out rather than failing the turn: /// the send path has already said so, and a turn is a worse place to learn /// it. diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 49260af..45ca024 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -342,6 +342,12 @@ pub struct TranscriptFile { pub machine: String, pub machine_name: String, pub path: String, + /// How big the record is, for a reader deciding whether to open it. + /// Absent where the file could not be measured, which is a session that + /// has not written one yet -- and is deliberately not a zero, since "no + /// transcript" and "we could not look" are different answers. + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes: Option, } /// Where a session directory keeps its transcript. @@ -352,12 +358,12 @@ fn transcript_in(dir: &Path) -> PathBuf { /// Where `id`'s transcript is, said the way the explorer takes it. fn transcript_file(config: &Config, data_dir: &Path, id: &str) -> Option { let machine = config.machine(crate::config::LOCAL_MACHINE_ID)?; + let path = transcript_in(&data_dir.join(id)); Some(TranscriptFile { machine: machine.id.clone(), machine_name: machine.name.clone(), - path: transcript_in(&data_dir.join(id)) - .to_string_lossy() - .into_owned(), + bytes: std::fs::metadata(&path).ok().map(|file| file.len()), + path: path.to_string_lossy().into_owned(), }) }