From 6187958de30b9b16452308dbec9a2ff39da59335 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 28 Aug 2026 13:38:00 -0400 Subject: [PATCH] Let a llama session actually be started from the phone The driver worked and the models could be downloaded, but the spawn screen had no idea llama.cpp existed: the model field and every extra setting were gated behind `isClaude`, so a llama provider offered nothing, `model` arrived null, and the driver refused with "a llama.cpp session needs a model". The feature was reachable only by curl, which is not what was asked for. A llama provider now gets the models this backend has downloaded, as a picker rather than free text -- there is nothing sensible to type, and a name that is not on disk is a session that cannot start. Context size and temperature are there too, blank meaning llama.cpp's own default rather than a zero. Spawn stays disabled until a model is chosen, because without one the button could only fail. **Two bugs that only appeared by pressing the button**, both mine, both from changing the server without re-driving the app: - The app sent the setup's *label* where the server had started resolving by *id*. The failure was almost self-diagnosing -- `no setup named "this machine" -- configured: this machine` -- and that message now says "no setup with id" and lists ids, since listing labels was what made it read as a contradiction. - The session header showed `on local`, the id, because the app read `setup` where the server had begun sending both `setup` (id) and `setupName` (label). The app now carries only the label: nothing in it addresses a setup, and holding both is what let it show the wrong one. Verified by doing it: rediscovered the local machine from the phone so `local-llama` appeared, spawned a session on Qwen3-0.6B-Q8_0 with a 4096 context, sent "Reply with exactly one word: ready", and it replied "ready" with 125 tokens counted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw --- .../src/main/kotlin/com/example/aiapp/Api.kt | 8 +- .../com/example/aiapp/SessionListScreen.kt | 2 +- .../kotlin/com/example/aiapp/SessionScreen.kt | 2 +- .../kotlin/com/example/aiapp/SpawnScreen.kt | 80 ++++++++++++++++++- server/src/session/mod.rs | 8 +- 5 files changed, 91 insertions(+), 9 deletions(-) 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 50cc292..0cf069a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -110,7 +110,11 @@ private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Chars // which of that machine's providers it runs. data class SessionSummary( val id: String, - val setup: String, + /** + * The machine's current label. The id is deliberately not carried: nothing here addresses a + * setup, and holding both invites showing the wrong one, which is what happened. + */ + val setupName: String, val provider: String, val title: String, val model: String?, @@ -121,7 +125,7 @@ data class SessionSummary( private fun parseSession(session: JSONObject) = SessionSummary( id = session.getString("id"), - setup = session.getString("setup"), + setupName = session.getString("setupName"), provider = session.getString("provider"), title = session.getString("title"), model = session.optString("model").ifEmpty { null }, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 48b026b..92b3e57 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -218,7 +218,7 @@ private fun SessionCard( // than a bare name, so a host isn't mistaken for a model. listOfNotNull( session.provider, - "on ${session.setup}", + "on ${session.setupName}", session.model, ) .joinToString(" ยท "), 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 2f495e7..1edf39e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -242,7 +242,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () Text( listOfNotNull( summary.provider, - "on ${summary.setup}", + "on ${summary.setupName}", summary.model, if (totalTokens > 0) "$totalTokens tok" else null, ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index de0d0b9..4c56cfb 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -73,6 +73,13 @@ fun SpawnScreen( // one leaves a filled-in form worth keeping, and that one leaves // nothing to fill in. var spawnError by remember { mutableStateOf(null) } + // Downloaded models, for a llama provider to choose between. Fetched + // beside the setups but kept separate: a Claude session needs none, so + // failing to list them must not stop the screen rendering. + var models by remember { mutableStateOf>(emptyList()) } + var modelKey by remember { mutableStateOf(null) } + var contextSize by remember { mutableStateOf("") } + var temperature by remember { mutableStateOf("") } LaunchedEffect(Unit) { options = @@ -85,6 +92,9 @@ fun SpawnScreen( } catch (e: ApiException) { LoadState.failed(e) } + models = + runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } } + .getOrDefault(emptyList()) } Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { @@ -121,6 +131,7 @@ fun SpawnScreen( // than the provider name keeps a second Claude provider from // needing anything here. val isClaude = current?.kind == "claude_cli" + val isLlama = current?.kind == "llama_cpp" // The machine first, because it decides what can be run at all. ChipGroup( @@ -174,6 +185,49 @@ fun SpawnScreen( modifier = Modifier.fillMaxWidth(), ) + if (isLlama) { + // A llama session names one of the models this backend has + // downloaded, so the choice is that list rather than free + // text -- there is nothing sensible to type here, and a name + // that is not on disk is a session that cannot start. + if (models.isEmpty()) { + Text( + "No models downloaded yet. Get one from the Models screen first.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + ChipGroup( + label = "Model", + // The file, not the whole key: the repository is the + // same for every quantisation of a model, so the file + // name is what tells two of them apart. + options = models.map { it.file }, + selected = models.firstOrNull { it.key == modelKey }?.file, + onSelect = { file -> modelKey = models.first { it.file == file }.key }, + ) + } + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = contextSize, + onValueChange = { contextSize = it }, + label = { Text("Context size (blank = the model's default)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = temperature, + onValueChange = { temperature = it }, + label = { Text("Temperature (blank = llama.cpp's default)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } + if (isClaude) { if (current.models.isNotEmpty()) { Spacer(Modifier.height(16.dp)) @@ -229,12 +283,32 @@ fun SpawnScreen( withContext(Dispatchers.IO) { spawnSession( settings, - setup = setup?.name.orEmpty(), + // The id, not the label: labels are + // editable and the server resolves by + // id. + setup = setup?.id.orEmpty(), provider = chosen.name, title = title.trim(), - model = model.trim().takeIf { isClaude }, + model = + if (isLlama) modelKey else model.trim().takeIf { isClaude }, cwd = cwd.trim().takeIf { isClaude }, permissionMode = permissionMode.takeIf { isClaude }, + // Sent only when set, so blank means + // "whatever llama.cpp does by default" + // rather than a zero. + params = + buildMap { + if (isLlama) { + contextSize + .trim() + .takeIf { it.isNotEmpty() } + ?.let { put("contextSize", it) } + temperature + .trim() + .takeIf { it.isNotEmpty() } + ?.let { put("temperature", it) } + } + }, ) } onSpawned(spawned) @@ -244,7 +318,7 @@ fun SpawnScreen( } } }, - enabled = !busy && current != null, + enabled = !busy && current != null && !(isLlama && modelKey == null), ) { Text(if (busy) "Spawning..." else "Spawn") } diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index f9b1fbc..cae987e 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -456,9 +456,13 @@ impl SessionManager { .setup(&spec.setup) .with_context(|| { format!( - "no setup named \"{}\" -- configured: {}", + "no setup with id \"{}\" -- configured: {}", spec.setup, - names(inner.config.setups.iter().map(|s| s.name.as_str())), + // Ids, since that is what was looked up. Listing the + // labels made the failure read as a contradiction: + // "no setup named X -- configured: X", when X was a + // label and the id was something else. + names(inner.config.setups.iter().map(|s| s.id.as_str())), ) })? .clone();