The settings dialog had outgrown a dialog: it scrolled inside itself, covered the session it is about, and had nowhere to put a second tab. It is a screen now, drawn over the session like the file explorer so the session under it stays composed, with the back gesture to leave it. The second tab is ProviderScreen itself -- the same composable the machines tab opens -- so a provider's settings have two ways in and one implementation. Every text field in the app goes through LabelledField: the label is a line above the box rather than a thing floating inside it, the hint says what leaving it blank means, and the padding is one line's worth. Material's outlined field spends the height of three lines to hold one, which on a form of a dozen settings is a screen and a half of scrolling. The value's own text is unchanged -- the framing was what cost. A session also gets a system prompt, which for llama.cpp is one entry in the params table and no app change: it rides in front of the conversation on every request rather than being recorded as the first thing in it, so changing it takes effect on the next message. ParamKind::Prose is new because a paragraph in a one-line box shows six words of itself.
483 lines
20 KiB
Kotlin
483 lines
20 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.BorderStroke
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.semantics.contentDescription
|
|
import androidx.compose.ui.semantics.semantics
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* The machines this backend can run things on.
|
|
*
|
|
* Note what this screen cannot do: name a program. Providers are what the server found when it
|
|
* asked the machine, so adding one is "here is how to reach it" and never "here is what to run" --
|
|
* which is what keeps the enrolled token from being able to introduce commands.
|
|
*/
|
|
@Composable
|
|
fun MachinesScreen(
|
|
settings: ServerSettings,
|
|
reloadToken: Int,
|
|
/** Opens one provider on one machine -- its settings, and what its server is holding. */
|
|
onProvider: (String, String) -> Unit,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
var state by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
|
var adding by remember { mutableStateOf(false) }
|
|
var renaming by remember { mutableStateOf<Machine?>(null) }
|
|
var confirmingDelete by remember { mutableStateOf<Machine?>(null) }
|
|
var signingIn by remember { mutableStateOf<Pair<Machine, Provider>?>(null) }
|
|
var busy by remember { mutableStateOf<String?>(null) }
|
|
var actionError by remember { mutableStateOf<String?>(null) }
|
|
|
|
suspend fun reload() {
|
|
state =
|
|
try {
|
|
withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) }
|
|
} catch (e: ApiException) {
|
|
LoadState.failed(e)
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(reloadToken) { reload() }
|
|
|
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
|
// The heading and Back are the tab row's now; adding a machine is this tab's own work and
|
|
// stays with the list it adds to.
|
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
TextButton(onClick = { adding = true }) { Text("Add machine") }
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
|
|
actionError?.let {
|
|
Text(it, color = MaterialTheme.colorScheme.error)
|
|
Spacer(Modifier.height(8.dp))
|
|
}
|
|
busy?.let {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
|
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
}
|
|
|
|
when (val current = state) {
|
|
is LoadState.Loading -> CircularProgressIndicator()
|
|
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
|
is LoadState.Loaded ->
|
|
LazyColumn(Modifier.fillMaxSize()) {
|
|
uniqueItems(current.value, key = { it.id }) { machine ->
|
|
MachineCard(
|
|
machine = machine,
|
|
onRename = { renaming = machine },
|
|
onRediscover = {
|
|
scope.launch {
|
|
busy = "Asking ${machine.name} what it has…"
|
|
actionError =
|
|
runCatching {
|
|
withContext(Dispatchers.IO) {
|
|
updateMachine(
|
|
settings,
|
|
machine.id,
|
|
rediscover = true,
|
|
)
|
|
}
|
|
}
|
|
.exceptionOrNull()
|
|
?.message
|
|
busy = null
|
|
reload()
|
|
}
|
|
},
|
|
onDelete = { confirmingDelete = machine },
|
|
onSignIn = { provider -> signingIn = machine to provider },
|
|
onProvider = { provider -> onProvider(machine.id, provider.name) },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (adding) {
|
|
AddMachineDialog(
|
|
onDismiss = { adding = false },
|
|
onAdd = { name, ssh ->
|
|
adding = false
|
|
scope.launch {
|
|
busy = "Asking $name what it has…"
|
|
actionError =
|
|
runCatching {
|
|
withContext(Dispatchers.IO) { addMachine(settings, name, ssh) }
|
|
}
|
|
.exceptionOrNull()
|
|
?.message
|
|
busy = null
|
|
reload()
|
|
}
|
|
},
|
|
onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } },
|
|
)
|
|
}
|
|
|
|
renaming?.let { machine ->
|
|
RenameDialog(
|
|
machine = machine,
|
|
onDismiss = { renaming = null },
|
|
onRename = { name ->
|
|
renaming = null
|
|
scope.launch {
|
|
actionError =
|
|
runCatching {
|
|
withContext(Dispatchers.IO) {
|
|
updateMachine(settings, machine.id, name = name)
|
|
}
|
|
}
|
|
.exceptionOrNull()
|
|
?.message
|
|
reload()
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
confirmingDelete?.let { machine ->
|
|
AlertDialog(
|
|
onDismissRequest = { confirmingDelete = null },
|
|
title = { Text("Remove \"${machine.name}\"?") },
|
|
text = {
|
|
Text(
|
|
"The machine is left alone -- this only stops this app offering it. " +
|
|
"Sessions still running on it must be deleted first."
|
|
)
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = {
|
|
confirmingDelete = null
|
|
scope.launch {
|
|
actionError =
|
|
runCatching {
|
|
withContext(Dispatchers.IO) {
|
|
deleteMachine(settings, machine.id)
|
|
}
|
|
}
|
|
.exceptionOrNull()
|
|
?.message
|
|
reload()
|
|
}
|
|
}
|
|
) {
|
|
Text("Remove")
|
|
}
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
|
},
|
|
)
|
|
}
|
|
|
|
signingIn?.let { (machine, provider) ->
|
|
ProviderLoginDialog(
|
|
settings = settings,
|
|
machineId = machine.id,
|
|
machineName = machine.name,
|
|
provider = provider.name,
|
|
onDismiss = { signingIn = null },
|
|
onSignedIn = {
|
|
signingIn = null
|
|
scope.launch { reload() }
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun MachineCard(
|
|
machine: Machine,
|
|
onRename: () -> Unit,
|
|
onRediscover: () -> Unit,
|
|
onDelete: () -> Unit,
|
|
onSignIn: (Provider) -> Unit,
|
|
onProvider: (Provider) -> Unit,
|
|
) {
|
|
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
Text(machine.name, style = MaterialTheme.typography.titleSmall)
|
|
Text(
|
|
// Not "this machine": the seeded machine is *called* that, and the card read "this
|
|
// machine / this machine".
|
|
machine.address ?: "runs where the backend does",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(4.dp))
|
|
if (machine.providers.isEmpty()) {
|
|
Text(
|
|
"Nothing found on it. Install something and rediscover.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
} else {
|
|
machine.providers.forEach { provider ->
|
|
// A card of its own rather than a line of text: a provider is where the
|
|
// settings that belong to *this machine* live -- how each of its models is
|
|
// loaded, the models themselves, and the server holding them -- and those had
|
|
// nowhere to be until one llama-server came to serve every session on a
|
|
// machine. Sized by its own padding rather than by whatever control happened
|
|
// to be on its row, like the tool call cards it is built after.
|
|
Card(
|
|
Modifier.fillMaxWidth()
|
|
.padding(vertical = 4.dp)
|
|
.clickable { onProvider(provider) }
|
|
.semantics { contentDescription = "Open ${provider.name}" },
|
|
// A border, and the machine card's own surface kept underneath it.
|
|
// The tint that was here before is one step along the surface ladder
|
|
// from the card it sits in, and two adjacent surfaces render as one flat
|
|
// block: these read as lines of text in a box rather than as things to
|
|
// open. One cue, and a visible one.
|
|
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
|
) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
|
) {
|
|
Column(Modifier.weight(1f)) {
|
|
Text(provider.name, style = MaterialTheme.typography.titleSmall)
|
|
// What was actually found, which is the honest second line and
|
|
// the one thing here nobody can change. No arrow: a card that
|
|
// lifts off the one behind it already reads as something to open,
|
|
// and the chevron was the only thing making these look like rows
|
|
// of a list.
|
|
provider.command?.let {
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
fontFamily = FontFamily.Monospace,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
maxLines = 1,
|
|
// A program is identified by its name, which is the tail
|
|
// of its path.
|
|
overflow = TextOverflow.StartEllipsis,
|
|
)
|
|
}
|
|
}
|
|
if (provider.kind == "claude_cli") {
|
|
TextButton(onClick = { onSignIn(provider) }) { Text("Sign in") }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
TextButton(onClick = onRename) { Text("Rename") }
|
|
TextButton(onClick = onRediscover) { Text("Rediscover") }
|
|
Spacer(Modifier.weight(1f))
|
|
TextButton(onClick = onDelete) { Text("Remove") }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun AddMachineDialog(
|
|
onDismiss: () -> Unit,
|
|
onAdd: (String, SshDetails?) -> Unit,
|
|
onTest: suspend (SshDetails?) -> List<Provider>,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
var name by remember { mutableStateOf("") }
|
|
var address by remember { mutableStateOf("") }
|
|
var identity by remember { mutableStateOf("") }
|
|
var attachmentsDir by remember { mutableStateOf("") }
|
|
var modelsDir by remember { mutableStateOf("") }
|
|
var tested by remember { mutableStateOf<String?>(null) }
|
|
var testing by remember { mutableStateOf(false) }
|
|
|
|
fun details(): SshDetails? =
|
|
address
|
|
.trim()
|
|
.takeIf { it.isNotEmpty() }
|
|
?.let { typed ->
|
|
val (host, typedPort) = splitHostAndPort(typed)
|
|
SshDetails(
|
|
address = host,
|
|
port = typedPort,
|
|
identityFile = identity.trim().ifEmpty { null },
|
|
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
|
modelsDir = modelsDir.trim().ifEmpty { null },
|
|
)
|
|
}
|
|
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("Add a machine") },
|
|
text = {
|
|
Column {
|
|
Text(
|
|
"Leave the address blank for the machine the backend runs on. " +
|
|
"What it can run is discovered, not typed.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
LabelledField(label = "Name", value = name, onValueChange = { name = it })
|
|
Spacer(Modifier.height(8.dp))
|
|
LabelledField(
|
|
// Just the shape. What a blank one means is said once, in the text above this
|
|
// form -- repeating it here wrapped the label onto a second line.
|
|
label = "user@host[:port]",
|
|
value = address,
|
|
onValueChange = { address = it },
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
LabelledField(
|
|
label = "Key path on the backend",
|
|
value = identity,
|
|
onValueChange = { identity = it },
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
LabelledField(
|
|
// Where a file attached from the phone lands on that machine.
|
|
label = "Folder for attached files",
|
|
value = attachmentsDir,
|
|
onValueChange = { attachmentsDir = it },
|
|
hint = "the session's own directory",
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
LabelledField(
|
|
// Where that machine's GGUFs are, for a llama.cpp session on it.
|
|
label = "Folder for models",
|
|
value = modelsDir,
|
|
onValueChange = { modelsDir = it },
|
|
hint = "the same place this backend keeps its own downloads",
|
|
)
|
|
tested?.let {
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(enabled = name.isNotBlank(), onClick = { onAdd(name.trim(), details()) }) {
|
|
Text("Add")
|
|
}
|
|
},
|
|
dismissButton = {
|
|
Row {
|
|
// Tried before saving, so a wrong address or an unauthorised key is caught while
|
|
// this form is still on screen rather than at the first spawn.
|
|
TextButton(
|
|
enabled = !testing,
|
|
onClick = {
|
|
testing = true
|
|
tested = "Asking…"
|
|
scope.launch {
|
|
tested =
|
|
runCatching { onTest(details()) }
|
|
.fold(
|
|
onSuccess = { found ->
|
|
if (found.isEmpty()) {
|
|
"Reached it, but found nothing it can run."
|
|
} else {
|
|
"Found ${found.joinToString(", ") { it.name }}"
|
|
}
|
|
},
|
|
onFailure = { it.message ?: "Couldn't reach it" },
|
|
)
|
|
testing = false
|
|
}
|
|
},
|
|
) {
|
|
Text("Test")
|
|
}
|
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
|
var name by remember { mutableStateOf(machine.name) }
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("Rename") },
|
|
text = {
|
|
Column {
|
|
LabelledField(label = "Name", value = name, onValueChange = { name = it })
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(
|
|
"Sessions already running on it keep working -- they refer to the machine, " +
|
|
"not to what it is called.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(enabled = name.isNotBlank(), onClick = { onRename(name.trim()) }) {
|
|
Text("Rename")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Splits `user@host:port` into its two halves, with the port left null when none was typed.
|
|
*
|
|
* One field rather than two because that is how an address is written and read everywhere else, and
|
|
* because a port that is almost always 22 does not deserve a box of its own on a phone keyboard.
|
|
* Null rather than 22: the backend already decides the default.
|
|
*
|
|
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
|
|
* `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon
|
|
* followed by digits.
|
|
*/
|
|
private fun splitHostAndPort(typed: String): Pair<String, Int?> {
|
|
if (typed.startsWith("[")) {
|
|
val close = typed.indexOf(']')
|
|
if (close > 0) {
|
|
val host = typed.substring(1, close)
|
|
val rest = typed.substring(close + 1)
|
|
val port = rest.removePrefix(":").toIntOrNull().takeIf { rest.startsWith(":") }
|
|
return host to port
|
|
}
|
|
}
|
|
if (typed.count { it == ':' } == 1) {
|
|
val host = typed.substringBeforeLast(':')
|
|
val port = typed.substringAfterLast(':').toIntOrNull()
|
|
if (port != null && host.isNotEmpty()) return host to port
|
|
}
|
|
return typed to null
|
|
}
|