The Models tab was about this backend's own disk, which is the wrong disk for every session that runs anywhere else: llama.cpp reads the file where it runs. So the models of a machine live under that machine's llama.cpp provider now, beside the settings deciding how each is loaded, and the download that produces one happens there. A download is a detached `curl` on that machine, started by a script this server writes and never spoken to again. Its state is a file beside the partial, so nothing about it is held here: it survives the app closing, this backend restarting and a second device watching, and the progress is `wc -c` of the partial against the size HuggingFace published rather than anything remembered. A run whose process is gone is reported failed, since `kill -0` is asked at each listing, and there is no "finished" state -- a download that finished is a model, in the list beside the ones still going. Resuming is guarded by the published sha256, which is also checked before the file takes its real name. Two other things the same screens wanted: A provider is drawn as a card rather than as a line of text, bordered against the machine card it sits in -- the tint it had was one step along the surface ladder and rendered as one flat block -- with room to tap and no chevron. Nothing in a raw block wraps any more; the block scrolls sideways instead, one offset for all its lines, so a diff or a column-aligned test run still reads as one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
485 lines
21 KiB
Kotlin
485 lines
21 KiB
Kotlin
package com.example.aiapp
|
|
|
|
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.imePadding
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.foundation.verticalScroll
|
|
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.mutableIntStateOf
|
|
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.text.font.FontFamily
|
|
import androidx.compose.ui.text.input.KeyboardType
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.window.DialogProperties
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* One provider on one machine: what it is, what its shared server is holding, and how each of its
|
|
* models is loaded.
|
|
*
|
|
* This is where a setting that belongs to a *machine* lives, as opposed to one that belongs to a
|
|
* session. The two were one list until llama.cpp sessions came to share one server per machine: how
|
|
* a model is loaded stopped being anything a single session could decide, because one copy of it in
|
|
* memory is what several sessions are talking to.
|
|
*
|
|
* It is also the only place a loaded model is taken out of memory. Nothing does that on its own —
|
|
* closing a session leaves the model loaded on purpose, since the next one to want it would
|
|
* otherwise pay the load again — so the memory is freed here, where what it costs everybody is
|
|
* visible.
|
|
*/
|
|
@Composable
|
|
fun ProviderScreen(
|
|
settings: ServerSettings,
|
|
machineId: String,
|
|
provider: String,
|
|
onBack: () -> Unit,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
var state by remember { mutableStateOf<LoadState<ProviderView>>(LoadState.Loading) }
|
|
var reload by remember { mutableIntStateOf(0) }
|
|
var editing by remember { mutableStateOf<ProviderModel?>(null) }
|
|
var confirmingStop by remember { mutableStateOf(false) }
|
|
// What is being done to the server or to one of its models, in a word, and what went wrong
|
|
// when it did. Both here rather than per row: these act on the whole machine.
|
|
var busy by remember { mutableStateOf<String?>(null) }
|
|
var actionError by remember { mutableStateOf<String?>(null) }
|
|
var confirmingDelete by remember { mutableStateOf<ProviderModel?>(null) }
|
|
|
|
// The machine's own models and what is being fetched onto it. Only for a provider that serves
|
|
// files off that machine's disk -- everything else names its models rather than holding them,
|
|
// and a search for a GGUF under the Claude CLI would be an offer that leads nowhere.
|
|
val kind = (state as? LoadState.Loaded)?.value?.kind
|
|
val machineModels =
|
|
rememberMachineModels(
|
|
settings = settings,
|
|
machineId = machineId,
|
|
enabled = kind == "llama_cpp",
|
|
// A download that became a model is a model this screen has no settings for yet, so
|
|
// the view it is drawing is now one model short of the truth.
|
|
onLocalChange = { reload++ },
|
|
)
|
|
|
|
LaunchedEffect(reload) {
|
|
state =
|
|
try {
|
|
withContext(Dispatchers.IO) {
|
|
LoadState.Loaded(fetchProvider(settings, machineId, provider))
|
|
}
|
|
} catch (e: ApiException) {
|
|
LoadState.failed(e)
|
|
}
|
|
}
|
|
|
|
// Say what is happening, do it, say what went wrong, refetch: every action on this screen
|
|
// changes what it is showing.
|
|
val act = { what: String, action: suspend () -> Unit ->
|
|
scope.launch {
|
|
busy = what
|
|
actionError =
|
|
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
|
|
busy = null
|
|
reload++
|
|
}
|
|
Unit
|
|
}
|
|
|
|
// The models search at the bottom takes the keyboard, and everything below the field it is
|
|
// typed in -- the Search button, the results -- is behind it without this.
|
|
Column(Modifier.fillMaxSize().imePadding().padding(16.dp)) {
|
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
TextButton(onClick = onBack) { Text("Back") }
|
|
}
|
|
when (val current = state) {
|
|
is LoadState.Loading -> CircularProgressIndicator()
|
|
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
|
is LoadState.Loaded -> {
|
|
val view = current.value
|
|
Text(view.name, style = MaterialTheme.typography.titleMedium)
|
|
Text(
|
|
"on ${view.machine}",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
view.command?.let {
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
fontFamily = FontFamily.Monospace,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
Spacer(Modifier.height(12.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))
|
|
}
|
|
|
|
LazyColumn(Modifier.fillMaxSize()) {
|
|
view.server?.let { server ->
|
|
item("server") {
|
|
ServerCard(
|
|
server = server,
|
|
maxLoaded = view.maxLoaded,
|
|
enabled = busy == null,
|
|
onStop = { confirmingStop = true },
|
|
onMaxLoaded = { chosen ->
|
|
act("Saving…") {
|
|
setProviderSettings(
|
|
settings,
|
|
machineId,
|
|
provider,
|
|
chosen,
|
|
)
|
|
}
|
|
},
|
|
)
|
|
Spacer(Modifier.height(12.dp))
|
|
}
|
|
}
|
|
if (view.models.isNotEmpty() && view.modelParams.isNotEmpty()) {
|
|
item("models-heading") {
|
|
Text("Models", style = MaterialTheme.typography.titleSmall)
|
|
Text(
|
|
"How a model is loaded belongs to the machine, not to a session: " +
|
|
"one copy of it in memory answers every session using it.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
}
|
|
}
|
|
machineModels.actionError?.let { failure ->
|
|
item("models-error") {
|
|
Text(failure, color = MaterialTheme.colorScheme.error)
|
|
}
|
|
}
|
|
// Above the models: this is what is about to be one of them.
|
|
downloadCards(machineModels)
|
|
val sizes = machineModels.sizes
|
|
uniqueItems(view.models, key = { it.id }) { model ->
|
|
ModelCard(
|
|
model = model,
|
|
specs = view.modelParams,
|
|
bytes = sizes[model.id],
|
|
onDelete =
|
|
if (model.id in sizes) ({ confirmingDelete = model }) else null,
|
|
// Tapping opens the settings; a provider whose models take none has
|
|
// nothing to open, so the row is not a control.
|
|
onEdit =
|
|
if (view.modelParams.isEmpty()) null else ({ editing = model }),
|
|
onUnload =
|
|
if (model.status == "loaded" || model.status == "sleeping") {
|
|
{
|
|
act("Unloading ${model.label}…") {
|
|
unloadProviderModel(
|
|
settings,
|
|
machineId,
|
|
provider,
|
|
model.id,
|
|
)
|
|
}
|
|
}
|
|
} else null,
|
|
enabled = busy == null,
|
|
)
|
|
}
|
|
if (kind == "llama_cpp") modelSearch(machineModels)
|
|
if (view.mcpServers.isNotEmpty()) {
|
|
item("mcp") {
|
|
Spacer(Modifier.height(12.dp))
|
|
Text("Tool servers", style = MaterialTheme.typography.titleSmall)
|
|
Text(
|
|
view.mcpServers.joinToString(", ") +
|
|
" — configured on the backend, in its config file.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
editing?.let { model ->
|
|
val view = (state as? LoadState.Loaded)?.value
|
|
ModelSettingsDialog(
|
|
model = model,
|
|
specs = view?.modelParams.orEmpty(),
|
|
onDismiss = { editing = null },
|
|
onSave = { params ->
|
|
editing = null
|
|
act("Saving ${model.label}…") {
|
|
setModelSettings(settings, machineId, provider, model.id, params)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
confirmingDelete?.let { model ->
|
|
AlertDialog(
|
|
onDismissRequest = { confirmingDelete = null },
|
|
title = { Text("Delete ${model.label}?") },
|
|
text = {
|
|
Text(
|
|
"The file is removed from ${(state as? LoadState.Loaded)?.value?.machine ?: "this machine"}. " +
|
|
"Nothing here can get it back -- downloading it again is the whole file again. " +
|
|
"Sessions using it keep their conversations and cannot start it."
|
|
)
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = {
|
|
confirmingDelete = null
|
|
machineModels.remove(model.id)
|
|
}
|
|
) {
|
|
Text("Delete")
|
|
}
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
|
},
|
|
)
|
|
}
|
|
|
|
if (confirmingStop) {
|
|
AlertDialog(
|
|
onDismissRequest = { confirmingStop = false },
|
|
title = { Text("Stop this server?") },
|
|
text = {
|
|
// Said plainly rather than hidden: this is the only thing that frees the memory,
|
|
// and what it costs is that every session on this machine reloads its model.
|
|
Text(
|
|
"Every model it is holding is unloaded. Sessions using it will show as " +
|
|
"exited, and the next message to one loads its model again — which is " +
|
|
"the slow part, not the sending."
|
|
)
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = {
|
|
confirmingStop = false
|
|
act("Stopping…") { stopProviderServer(settings, machineId, provider) }
|
|
}
|
|
) {
|
|
Text("Stop")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = { confirmingStop = false }) { Text("Cancel") } },
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ServerCard(
|
|
server: ServerState,
|
|
maxLoaded: Int?,
|
|
enabled: Boolean,
|
|
onStop: () -> Unit,
|
|
onMaxLoaded: (Int?) -> Unit,
|
|
) {
|
|
// The saved value is what this starts at and what Save is compared against, so a field left
|
|
// half-typed is visibly not saved rather than quietly either way.
|
|
val saved = maxLoaded?.toString().orEmpty()
|
|
var typed by remember(saved) { mutableStateOf(saved) }
|
|
Card(Modifier.fillMaxWidth()) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
Text("Model server", style = MaterialTheme.typography.titleSmall)
|
|
Text(
|
|
if (server.running) {
|
|
"Running" + (server.port?.let { ", reached on port $it" } ?: "")
|
|
} else {
|
|
// Not a fault: nothing is loaded because nothing has asked. Saying it in
|
|
// words rather than colouring the row, since "stopped" and "we could not
|
|
// ask" would otherwise look the same.
|
|
"Not running. A session starts it when it needs a model."
|
|
},
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
OutlinedTextField(
|
|
value = typed,
|
|
onValueChange = { typed = it.filter(Char::isDigit) },
|
|
label = { Text("Models loaded at once") },
|
|
placeholder = { Text("one -- a second model replaces the first") },
|
|
singleLine = true,
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
modifier = Modifier.fillMaxWidth(),
|
|
)
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
// Shown whether or not it is running, and disabled when there is nothing to stop:
|
|
// a button that comes and goes makes its own absence the message.
|
|
TextButton(enabled = enabled && server.running, onClick = onStop) { Text("Stop") }
|
|
Spacer(Modifier.weight(1f))
|
|
TextButton(
|
|
enabled = enabled && typed != saved,
|
|
onClick = { onMaxLoaded(typed.toIntOrNull()) },
|
|
) {
|
|
Text("Save")
|
|
}
|
|
}
|
|
if (typed != saved) {
|
|
Text(
|
|
"Read when this server next starts.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ModelCard(
|
|
model: ProviderModel,
|
|
specs: List<ParamSpec>,
|
|
/** How big the file is on the machine, for a provider whose models are files. */
|
|
bytes: Long?,
|
|
onEdit: (() -> Unit)?,
|
|
onUnload: (() -> Unit)?,
|
|
onDelete: (() -> Unit)?,
|
|
enabled: Boolean,
|
|
) {
|
|
Card(
|
|
Modifier.fillMaxWidth()
|
|
.padding(vertical = 4.dp)
|
|
.then(if (onEdit != null && enabled) Modifier.clickable(onClick = onEdit) else Modifier)
|
|
) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
model.label,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
bytes?.let {
|
|
Text(
|
|
gigabytes(it),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
// What the server is doing with it, in its own word. Absent means nobody could ask --
|
|
// the server is not running -- and the line is left out rather than guessed at.
|
|
model.status?.let {
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
if (model.settings.isNotEmpty()) {
|
|
Text(
|
|
// In the words the dialog uses, and in the order it draws them: a summary
|
|
// naming `contextSize` is a summary of a different screen than the one it
|
|
// sits under.
|
|
specs
|
|
.mapNotNull { spec ->
|
|
model.settings[spec.key]?.let { "${spec.label} $it" }
|
|
}
|
|
.joinToString(", "),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
if (onUnload != null || onDelete != null) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
// Both shown whenever this kind of model has them, disabled rather than
|
|
// absent: unloading frees memory and deleting frees disk, and a button that
|
|
// comes and goes makes its own absence the message.
|
|
onUnload?.let { TextButton(enabled = enabled, onClick = it) { Text("Unload") } }
|
|
Spacer(Modifier.weight(1f))
|
|
onDelete?.let { TextButton(enabled = enabled, onClick = it) { Text("Delete") } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How one model is loaded.
|
|
*
|
|
* Saved on Save rather than as it is typed, unlike the session settings dialog: writing this
|
|
* unloads the model for everybody using it, which is not something to do once per keystroke.
|
|
*/
|
|
@Composable
|
|
private fun ModelSettingsDialog(
|
|
model: ProviderModel,
|
|
specs: List<ParamSpec>,
|
|
onDismiss: () -> Unit,
|
|
onSave: (Map<String, String>) -> Unit,
|
|
) {
|
|
var params by remember(model.id) { mutableStateOf(model.settings) }
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
// Every control here is a number, so the keyboard is up for most of this dialog's life --
|
|
// and a dialog that keeps its own size under the keyboard puts Save off the bottom of the
|
|
// screen, where nothing on screen says it is there. Taking the insets ourselves is what
|
|
// lets `imePadding` shrink it instead.
|
|
properties = DialogProperties(decorFitsSystemWindows = false),
|
|
modifier = Modifier.imePadding(),
|
|
title = { Text(model.label) },
|
|
text = {
|
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
|
Text(
|
|
if (model.status == "loaded" || model.status == "sleeping") {
|
|
"This model is loaded. Saving takes it out of memory, and the sessions " +
|
|
"using it load it again with these settings on their next message."
|
|
} else {
|
|
"Read when this model is next loaded."
|
|
},
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(12.dp))
|
|
ProviderParamFields(
|
|
specs = specs,
|
|
values = params,
|
|
onChange = { params = it },
|
|
// Every one of these is read at load time, and the sentence above already
|
|
// says when that is -- marking each control "on restart" would repeat it six
|
|
// times.
|
|
warnAboutRestart = false,
|
|
)
|
|
}
|
|
},
|
|
confirmButton = { TextButton(onClick = { onSave(params) }) { Text("Save") } },
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
|
)
|
|
}
|