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();