441 lines
18 KiB
Kotlin
441 lines
18 KiB
Kotlin
package com.example.aiapp
|
|
|
|
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.CircularProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedTextField
|
|
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.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) {
|
|
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 },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
) {
|
|
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 ->
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text(provider.name, style = MaterialTheme.typography.bodySmall)
|
|
if (provider.kind == "claude_cli") {
|
|
Spacer(Modifier.weight(1f))
|
|
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))
|
|
OutlinedTextField(
|
|
value = name,
|
|
onValueChange = { name = it },
|
|
label = { Text("Name") },
|
|
singleLine = true,
|
|
)
|
|
OutlinedTextField(
|
|
value = address,
|
|
onValueChange = { address = it },
|
|
// 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 = { Text("user@host[:port]") },
|
|
singleLine = true,
|
|
)
|
|
OutlinedTextField(
|
|
value = identity,
|
|
onValueChange = { identity = it },
|
|
label = { Text("Key path on the backend") },
|
|
singleLine = true,
|
|
)
|
|
// Where a file attached from the phone lands on that machine. Blank means the
|
|
// session's own directory, which is what most people want.
|
|
OutlinedTextField(
|
|
value = attachmentsDir,
|
|
onValueChange = { attachmentsDir = it },
|
|
label = { Text("Folder for attached files (optional)") },
|
|
singleLine = true,
|
|
)
|
|
// Where that machine's GGUFs are, for a llama.cpp session on it. Blank means
|
|
// the same place this backend keeps its own downloads, read on that machine.
|
|
OutlinedTextField(
|
|
value = modelsDir,
|
|
onValueChange = { modelsDir = it },
|
|
label = { Text("Folder for models (optional)") },
|
|
singleLine = true,
|
|
)
|
|
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 {
|
|
OutlinedTextField(
|
|
value = name,
|
|
onValueChange = { name = it },
|
|
label = { Text("Name") },
|
|
singleLine = true,
|
|
)
|
|
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
|
|
}
|