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
@@ -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
|
`generate` now fails the turn for both -- a reply that stops early is not a
|
||||||
reply, and the transcript keeps whatever arrived before it.
|
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
|
- **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
|
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
|
question, a tool call `llama-server` is running (a shell command there runs
|
||||||
|
|||||||
@@ -251,8 +251,18 @@ data class SessionSummary(
|
|||||||
val transcriptFile: FileOnMachine?,
|
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) =
|
private fun parseSession(session: JSONObject) =
|
||||||
SessionSummary(
|
SessionSummary(
|
||||||
@@ -292,6 +302,7 @@ private fun parseSession(session: JSONObject) =
|
|||||||
machine = file.getString("machine"),
|
machine = file.getString("machine"),
|
||||||
machineName = file.getString("machineName"),
|
machineName = file.getString("machineName"),
|
||||||
path = file.getString("path"),
|
path = file.getString("path"),
|
||||||
|
bytes = if (file.has("bytes")) file.getLong("bytes") else null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,12 +57,10 @@ fun LabelledField(
|
|||||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
) {
|
) {
|
||||||
Column(modifier.fillMaxWidth()) {
|
Column(modifier.fillMaxWidth()) {
|
||||||
Text(
|
// Body size in the ordinary text colour, which is what a setting's label is where the
|
||||||
label,
|
// control beside it is a switch or a picker. Smaller and greyer on the ones that are
|
||||||
style = MaterialTheme.typography.labelMedium,
|
// fields reads as two ranks of setting where there is one.
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Text(label, modifier = Modifier.padding(bottom = 2.dp))
|
||||||
modifier = Modifier.padding(bottom = 2.dp),
|
|
||||||
)
|
|
||||||
hint?.let {
|
hint?.let {
|
||||||
Text(
|
Text(
|
||||||
it,
|
it,
|
||||||
|
|||||||
@@ -180,12 +180,6 @@ fun ProviderScreen(
|
|||||||
if (view.models.isNotEmpty() && view.modelParams.isNotEmpty()) {
|
if (view.models.isNotEmpty() && view.modelParams.isNotEmpty()) {
|
||||||
item("models-heading") {
|
item("models-heading") {
|
||||||
Text("Models", style = MaterialTheme.typography.titleSmall)
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,6 +318,7 @@ private fun ServerCard(
|
|||||||
// half-typed is visibly not saved rather than quietly either way.
|
// half-typed is visibly not saved rather than quietly either way.
|
||||||
val saved = maxLoaded?.toString().orEmpty()
|
val saved = maxLoaded?.toString().orEmpty()
|
||||||
var typed by remember(saved) { mutableStateOf(saved) }
|
var typed by remember(saved) { mutableStateOf(saved) }
|
||||||
|
var confirming by remember { mutableStateOf(false) }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
Text("Model server", style = MaterialTheme.typography.titleSmall)
|
Text("Model server", style = MaterialTheme.typography.titleSmall)
|
||||||
@@ -354,20 +349,55 @@ private fun ServerCard(
|
|||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
TextButton(
|
TextButton(
|
||||||
enabled = enabled && typed != saved,
|
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")
|
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
|
@Composable
|
||||||
@@ -452,6 +482,10 @@ private fun ModelSettingsDialog(
|
|||||||
onSave: (Map<String, String>) -> Unit,
|
onSave: (Map<String, String>) -> Unit,
|
||||||
) {
|
) {
|
||||||
var params by remember(model.id) { mutableStateOf(model.settings) }
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
// Every control here is a number, so the keyboard is up for most of this dialog's life --
|
// 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) },
|
title = { Text(model.label) },
|
||||||
text = {
|
text = {
|
||||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
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(
|
ProviderParamFields(
|
||||||
specs = specs,
|
specs = specs,
|
||||||
values = params,
|
values = params,
|
||||||
onChange = { params = it },
|
onChange = { params = it },
|
||||||
// Every one of these is read at load time, and the sentence above already
|
// Every one of these is read at load time, which is what Save stops to say --
|
||||||
// says when that is -- marking each control "on restart" would repeat it six
|
// marking each control "on restart" would repeat it six times over.
|
||||||
// times.
|
|
||||||
warnAboutRestart = false,
|
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") } },
|
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,
|
params = params,
|
||||||
onParamsChanged = { params = it },
|
onParamsChanged = { params = it },
|
||||||
cachedBytes = cachedBytes,
|
cachedBytes = cachedBytes,
|
||||||
|
transcriptBytes = summary.transcriptFile?.bytes,
|
||||||
// The purge finishes before the epoch moves, because the relaunched opening effect
|
// 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
|
// reads the same directory and would otherwise draw what is about to be deleted. The
|
||||||
// epoch is what makes the rest a cold open.
|
// 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.
|
* the Reload row below, which is what would discard it.
|
||||||
*/
|
*/
|
||||||
cachedBytes: Long?,
|
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,
|
onReload: () -> Unit,
|
||||||
/**
|
/**
|
||||||
* Opens the transcript file itself in the explorer. Null from a server that does not say where
|
* 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(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text("Transcript", modifier = Modifier.weight(1f))
|
Text("Transcript", modifier = Modifier.weight(1f))
|
||||||
// The size is what the button discards, and the unknown state is drawn rather
|
// What the conversation costs on the server, and then what Reload would
|
||||||
// than guessed: a spinner while the directory is being measured, and words when
|
// discard here -- one line, so the two sizes read as a pair. A server that
|
||||||
// there is nothing there, because "nothing cached" and "0 B" read as different
|
// did not measure its file leaves its half out rather than saying zero.
|
||||||
// claims.
|
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 {
|
when {
|
||||||
cachedBytes == null ->
|
cachedBytes == null -> {
|
||||||
|
onServer?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.width(16.dp).height(16.dp),
|
modifier = Modifier.width(16.dp).height(16.dp),
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
else ->
|
}
|
||||||
|
else -> {
|
||||||
|
val cached =
|
||||||
|
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached"
|
||||||
Text(
|
Text(
|
||||||
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
|
onServer?.let { "$it · $cached" } ?: cached,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Both on a line of their own under what they act on, rather than crowded against
|
// Both on a line of their own under what they act on, rather than crowded against
|
||||||
|
|||||||
+94
-1
@@ -74,7 +74,10 @@ pub struct LocalModel {
|
|||||||
pub fn labels(models: &[LocalModel]) -> Vec<String> {
|
pub fn labels(models: &[LocalModel]) -> Vec<String> {
|
||||||
let rungs = |model: &LocalModel| {
|
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.file.trim_end_matches(".gguf").to_string()),
|
||||||
Some(model.key.clone()),
|
Some(model.key.clone()),
|
||||||
]
|
]
|
||||||
@@ -106,6 +109,35 @@ pub fn labels(models: &[LocalModel]) -> Vec<String> {
|
|||||||
.collect()
|
.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<String> {
|
||||||
|
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.
|
/// What a download is doing, or did.
|
||||||
///
|
///
|
||||||
/// Flat rather than a tagged enum carrying its message, because the phone
|
/// Flat rather than a tagged enum carrying its message, because the phone
|
||||||
@@ -702,3 +734,64 @@ pub fn urlencode(value: &str) -> String {
|
|||||||
})
|
})
|
||||||
.collect()
|
.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"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
//! GET /sessions list (id, provider, title, model, status, last activity)
|
//! GET /sessions list (id, provider, title, model, status, last activity)
|
||||||
//! GET /sessions/{id} one session, for refetching after a change. Its
|
//! GET /sessions/{id} one session, for refetching after a change. Its
|
||||||
//! `transcriptFile` is where the record itself is --
|
//! `transcriptFile` is where the record itself is --
|
||||||
//! {machine, machineName, path} -- so the explorer can be
|
//! {machine, machineName, path, bytes?} -- so the explorer
|
||||||
//! pointed at it
|
//! can be pointed at it and its size said
|
||||||
//! POST /sessions spawn {machine, provider, title?, model?, cwd?, params?}
|
//! POST /sessions spawn {machine, provider, title?, model?, cwd?, params?}
|
||||||
//! POST /sessions/order {sessions} -- the list in the order it is drawn in,
|
//! POST /sessions/order {sessions} -- the list in the order it is drawn in,
|
||||||
//! as the reader dragged it; ids left out keep their
|
//! as the reader dragged it; ids left out keep their
|
||||||
|
|||||||
@@ -220,11 +220,46 @@ enum Content {
|
|||||||
Parts(Vec<Value>),
|
Parts(Vec<Value>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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>) -> String {
|
||||||
|
let text = text.into();
|
||||||
|
if text.contains(MEDIA_MARKER) {
|
||||||
|
return text.replace(MEDIA_MARKER, "");
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
impl Message {
|
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<String>) -> Self {
|
fn new(role: &str, content: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
role: role.to_string(),
|
role: role.to_string(),
|
||||||
content: Content::Text(content.into()),
|
content: Content::Text(without_marker(content)),
|
||||||
tool_calls: Vec::new(),
|
tool_calls: Vec::new(),
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
}
|
}
|
||||||
@@ -239,7 +274,7 @@ impl Message {
|
|||||||
/// templates, and only the first has ever been tested by everything else
|
/// templates, and only the first has ever been tested by everything else
|
||||||
/// here.
|
/// here.
|
||||||
fn from_user(text: impl Into<String>, images: Vec<Value>) -> Self {
|
fn from_user(text: impl Into<String>, images: Vec<Value>) -> Self {
|
||||||
let text = text.into();
|
let text = without_marker(text);
|
||||||
if images.is_empty() {
|
if images.is_empty() {
|
||||||
return Self::new("user", text);
|
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:
|
/// 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
|
/// the send path has already said so, and a turn is a worse place to learn
|
||||||
/// it.
|
/// it.
|
||||||
|
|||||||
@@ -342,6 +342,12 @@ pub struct TranscriptFile {
|
|||||||
pub machine: String,
|
pub machine: String,
|
||||||
pub machine_name: String,
|
pub machine_name: String,
|
||||||
pub path: 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<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a session directory keeps its transcript.
|
/// 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.
|
/// Where `id`'s transcript is, said the way the explorer takes it.
|
||||||
fn transcript_file(config: &Config, data_dir: &Path, id: &str) -> Option<TranscriptFile> {
|
fn transcript_file(config: &Config, data_dir: &Path, id: &str) -> Option<TranscriptFile> {
|
||||||
let machine = config.machine(crate::config::LOCAL_MACHINE_ID)?;
|
let machine = config.machine(crate::config::LOCAL_MACHINE_ID)?;
|
||||||
|
let path = transcript_in(&data_dir.join(id));
|
||||||
Some(TranscriptFile {
|
Some(TranscriptFile {
|
||||||
machine: machine.id.clone(),
|
machine: machine.id.clone(),
|
||||||
machine_name: machine.name.clone(),
|
machine_name: machine.name.clone(),
|
||||||
path: transcript_in(&data_dir.join(id))
|
bytes: std::fs::metadata(&path).ok().map(|file| file.len()),
|
||||||
.to_string_lossy()
|
path: path.to_string_lossy().into_owned(),
|
||||||
.into_owned(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user