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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
d0b6b66a44
commit
6187958de3
5 files changed
+91
-9
No files matched your search
@@ -110,7 +110,11 @@ private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Chars
|
|||||||
// which of that machine's providers it runs.
|
// which of that machine's providers it runs.
|
||||||
data class SessionSummary(
|
data class SessionSummary(
|
||||||
val id: String,
|
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 provider: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val model: String?,
|
val model: String?,
|
||||||
@@ -121,7 +125,7 @@ data class SessionSummary(
|
|||||||
private fun parseSession(session: JSONObject) =
|
private fun parseSession(session: JSONObject) =
|
||||||
SessionSummary(
|
SessionSummary(
|
||||||
id = session.getString("id"),
|
id = session.getString("id"),
|
||||||
setup = session.getString("setup"),
|
setupName = session.getString("setupName"),
|
||||||
provider = session.getString("provider"),
|
provider = session.getString("provider"),
|
||||||
title = session.getString("title"),
|
title = session.getString("title"),
|
||||||
model = session.optString("model").ifEmpty { null },
|
model = session.optString("model").ifEmpty { null },
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ private fun SessionCard(
|
|||||||
// than a bare name, so a host isn't mistaken for a model.
|
// than a bare name, so a host isn't mistaken for a model.
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
session.provider,
|
session.provider,
|
||||||
"on ${session.setup}",
|
"on ${session.setupName}",
|
||||||
session.model,
|
session.model,
|
||||||
)
|
)
|
||||||
.joinToString(" · "),
|
.joinToString(" · "),
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
|||||||
Text(
|
Text(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
summary.provider,
|
summary.provider,
|
||||||
"on ${summary.setup}",
|
"on ${summary.setupName}",
|
||||||
summary.model,
|
summary.model,
|
||||||
if (totalTokens > 0) "$totalTokens tok" else null,
|
if (totalTokens > 0) "$totalTokens tok" else null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ fun SpawnScreen(
|
|||||||
// one leaves a filled-in form worth keeping, and that one leaves
|
// one leaves a filled-in form worth keeping, and that one leaves
|
||||||
// nothing to fill in.
|
// nothing to fill in.
|
||||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
var spawnError by remember { mutableStateOf<String?>(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<List<LocalModel>>(emptyList()) }
|
||||||
|
var modelKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
var contextSize by remember { mutableStateOf("") }
|
||||||
|
var temperature by remember { mutableStateOf("") }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
options =
|
options =
|
||||||
@@ -85,6 +92,9 @@ fun SpawnScreen(
|
|||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
}
|
}
|
||||||
|
models =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||||
@@ -121,6 +131,7 @@ fun SpawnScreen(
|
|||||||
// than the provider name keeps a second Claude provider from
|
// than the provider name keeps a second Claude provider from
|
||||||
// needing anything here.
|
// needing anything here.
|
||||||
val isClaude = current?.kind == "claude_cli"
|
val isClaude = current?.kind == "claude_cli"
|
||||||
|
val isLlama = current?.kind == "llama_cpp"
|
||||||
|
|
||||||
// The machine first, because it decides what can be run at all.
|
// The machine first, because it decides what can be run at all.
|
||||||
ChipGroup(
|
ChipGroup(
|
||||||
@@ -174,6 +185,49 @@ fun SpawnScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
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 (isClaude) {
|
||||||
if (current.models.isNotEmpty()) {
|
if (current.models.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
@@ -229,12 +283,32 @@ fun SpawnScreen(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
spawnSession(
|
spawnSession(
|
||||||
settings,
|
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,
|
provider = chosen.name,
|
||||||
title = title.trim(),
|
title = title.trim(),
|
||||||
model = model.trim().takeIf { isClaude },
|
model =
|
||||||
|
if (isLlama) modelKey else model.trim().takeIf { isClaude },
|
||||||
cwd = cwd.trim().takeIf { isClaude },
|
cwd = cwd.trim().takeIf { isClaude },
|
||||||
permissionMode = permissionMode.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)
|
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")
|
Text(if (busy) "Spawning..." else "Spawn")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -456,9 +456,13 @@ impl SessionManager {
|
|||||||
.setup(&spec.setup)
|
.setup(&spec.setup)
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"no setup named \"{}\" -- configured: {}",
|
"no setup with id \"{}\" -- configured: {}",
|
||||||
spec.setup,
|
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();
|
.clone();
|
||||||
|
|||||||
Reference in new issue
Block a user