Download a model onto the machine that will serve it

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>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-19 18:55:51 -04:00
1 parent 8c323fc7a9
commit 81c30dcda1
14 files changed
+1224 -1073

No files matched your search

@@ -350,6 +350,8 @@ fun deleteSubagents(settings: ServerSettings, sessionId: String, subagentIds: Li
data class Provider(
val name: String,
val kind: String,
/** The program discovery found on that machine, or null for a provider that is not one. */
val command: String?,
val models: List<String>,
val permissionModes: List<String>,
val defaultPermissionMode: String?,
@@ -399,6 +401,7 @@ private fun parseProvider(provider: JSONObject): Provider {
return Provider(
name = provider.getString("name"),
kind = kind,
command = provider.optString("command").ifEmpty { null },
// Omitted entirely when the provider offers none.
models = provider.optJSONArray("models")?.strings().orEmpty(),
permissionModes = provider.optJSONArray("permissionModes")?.strings().orEmpty(),
@@ -1397,7 +1400,9 @@ fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Bo
requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {}
}
// Models: what this backend has downloaded, what it is downloading, and what HuggingFace offers.
// Models: what one machine has, what it is fetching onto itself, and what HuggingFace offers.
// Under a machine because that is whose disk the file is on -- `llama-server` reads it where it
// runs, so a list or a download naming anywhere else would be about the wrong filesystem.
// Browsing is proxied by the server rather than done here, because this app trusts exactly one
// certificate and has no general internet trust to spend on huggingface.co.
@@ -1415,12 +1420,14 @@ data class LocalModel(
)
/**
* A download in flight or finished. [total] is null when the server never said how big the file is
* A download in flight or stopped. [total] is null when HuggingFace never said how big the file is
* -- which must render as "not known", never as a bar at some invented position.
*
* There is no "finished": a download that finished is a model, and it is in [Models.local] beside
* this one.
*/
data class Download(
val key: String,
val run: Long,
val repo: String,
val file: String,
val state: String,
@@ -1435,18 +1442,98 @@ data class RemoteRepo(val id: String, val downloads: Long, val likes: Long)
data class RemoteFile(val path: String, val bytes: Long, val have: Boolean)
private fun parseDownload(o: JSONObject) =
Download(
key = o.getString("key"),
run = o.getLong("run"),
repo = o.getString("repo"),
file = o.getString("file"),
state = o.getString("state"),
done = o.getLong("done"),
// Absent rather than zero when unknown; see the field's comment.
total = if (o.has("total")) o.getLong("total") else null,
error = if (o.has("error")) o.getString("error") else null,
)
private fun modelsPath(machineId: String, tail: String = "") =
"/machines/${machineId.urlEncoded()}/models$tail"
/** Every GGUF on one machine, and every download putting one there. */
fun fetchMachineModels(settings: ServerSettings, machineId: String): Models =
requestFromServer(settings, modelsPath(machineId), readTimeoutMs = 40000) { connection ->
val body = connection.jsonObject()
Models(
local =
body.getJSONArray("local").mapObjects { m ->
LocalModel(
key = m.getString("key"),
repo = m.getString("repo"),
file = m.getString("file"),
bytes = m.getLong("bytes"),
name = m.optString("name").ifEmpty { null },
)
},
downloads =
body.getJSONArray("downloads").mapObjects { o ->
Download(
key = o.getString("key"),
repo = o.getString("repo"),
file = o.getString("file"),
state = o.getString("state"),
done = o.getLong("done"),
// Absent rather than zero when unknown; see the field's comment.
total = if (o.has("total")) o.getLong("total") else null,
error = if (o.has("error")) o.getString("error") else null,
)
},
)
}
fun searchModels(settings: ServerSettings, query: String): List<RemoteRepo> =
requestFromServer(settings, "/models/search?q=${query.urlEncoded()}") { connection ->
connection.jsonObjects { r ->
RemoteRepo(
id = r.getString("id"),
downloads = r.getLong("downloads"),
likes = r.getLong("likes"),
)
}
}
fun fetchRepoFiles(settings: ServerSettings, machineId: String, repo: String): List<RemoteFile> =
requestFromServer(
settings,
modelsPath(machineId, "/files?repo=${repo.urlEncoded()}"),
readTimeoutMs = 40000,
) { connection ->
connection.jsonObjects { f ->
RemoteFile(
path = f.getString("path"),
bytes = f.getLong("bytes"),
have = f.getBoolean("have"),
)
}
}
/** Starts one on that machine, or joins the run already going for the same model. */
fun startDownload(settings: ServerSettings, machineId: String, repo: String, file: String) {
requestFromServer(
settings,
modelsPath(machineId, "/download"),
method = "POST",
jsonBody = JSONObject().put("repo", repo).put("file", file).toString(),
readTimeoutMs = 40000,
) {}
}
/** Stops one. The partial stays on the machine, so starting again carries on from there. */
fun cancelDownload(settings: ServerSettings, machineId: String, key: String) {
requestFromServer(
settings,
modelsPath(machineId, "/cancel"),
method = "POST",
jsonBody = JSONObject().put("key", key).toString(),
readTimeoutMs = 40000,
) {}
}
/** Takes a model off that machine, downloaded or half-downloaded. */
fun deleteModel(settings: ServerSettings, machineId: String, key: String) {
requestFromServer(
settings,
modelsPath(machineId, "/delete"),
method = "POST",
jsonBody = JSONObject().put("key", key).toString(),
readTimeoutMs = 40000,
) {}
}
/**
* One model a picker can offer.
@@ -1612,71 +1699,3 @@ fun unloadProviderModel(
readTimeoutMs = 40000,
) {}
}
fun fetchModels(settings: ServerSettings): Models =
requestFromServer(settings, "/models") { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText())
Models(
local =
body.getJSONArray("local").mapObjects { m ->
LocalModel(
key = m.getString("key"),
repo = m.getString("repo"),
file = m.getString("file"),
bytes = m.getLong("bytes"),
name = m.optString("name").ifEmpty { null },
)
},
downloads = body.getJSONArray("downloads").mapObjects(::parseDownload),
)
}
fun searchModels(settings: ServerSettings, query: String): List<RemoteRepo> =
requestFromServer(settings, "/models/search?q=${query.urlEncoded()}") { connection ->
connection.jsonObjects { r ->
RemoteRepo(
id = r.getString("id"),
downloads = r.getLong("downloads"),
likes = r.getLong("likes"),
)
}
}
fun fetchRepoFiles(settings: ServerSettings, repo: String): List<RemoteFile> =
requestFromServer(settings, "/models/files?repo=${repo.urlEncoded()}") { connection ->
connection.jsonObjects { f ->
RemoteFile(
path = f.getString("path"),
bytes = f.getLong("bytes"),
have = f.getBoolean("have"),
)
}
}
fun startDownload(settings: ServerSettings, repo: String, file: String): Download =
requestFromServer(
settings,
"/models/download",
method = "POST",
jsonBody = JSONObject().put("repo", repo).put("file", file).toString(),
) { connection ->
parseDownload(JSONObject(connection.inputStream.bufferedReader().readText()))
}
fun cancelDownload(settings: ServerSettings, key: String) {
requestFromServer(
settings,
"/models/cancel",
method = "POST",
jsonBody = JSONObject().put("key", key).toString(),
) {}
}
fun deleteModel(settings: ServerSettings, key: String) {
requestFromServer(
settings,
"/models/delete",
method = "POST",
jsonBody = JSONObject().put("key", key).toString(),
) {}
}
@@ -0,0 +1,404 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
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.Stable
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.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The models on one machine, the downloads putting more there, and HuggingFace to find them in.
*
* This was a tab of its own, about the backend's own disk. It moved under the machine's llama.cpp
* provider on 2026-09-19, when a download came to run on the machine that will serve the file:
* there is no such thing as "the models", only this machine's, and the screen that decides how a
* model is loaded is the screen that should be able to fetch one.
*
* Everything here is the machine's state rather than this screen's. A download is a process on that
* machine with its progress written beside the partial file, so closing the app, locking the phone
* or restarting the backend does not touch it, and a second device watching sees the same numbers.
*/
@Stable
class MachineModelsState(
private val settings: ServerSettings,
private val machineId: String,
private val scope: CoroutineScope,
) {
var state by mutableStateOf<LoadState<Models>>(LoadState.Loading)
private set
var query by mutableStateOf("")
var results by mutableStateOf<LoadState<List<RemoteRepo>>?>(null)
private set
var openRepo by mutableStateOf<String?>(null)
private set
var repoFiles by mutableStateOf<LoadState<List<RemoteFile>>?>(null)
private set
/** What the last action said went wrong, shown above the list that action was taken in. */
var actionError by mutableStateOf<String?>(null)
private set
val models: Models?
get() = (state as? LoadState.Loaded)?.value
val downloads: List<Download>
get() = models?.downloads.orEmpty()
/** How big each downloaded model is, by key, for the cards the provider screen draws. */
val sizes: Map<String, Long>
get() = models?.local.orEmpty().associate { it.key to it.bytes }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchMachineModels(settings, machineId))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
/** Runs [action], says what it said if it failed, and asks the machine again either way. */
private fun act(action: suspend () -> Unit) {
scope.launch {
actionError =
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
reload()
}
}
fun search() {
openRepo = null
results = LoadState.Loading
scope.launch {
results =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(searchModels(settings, query)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
fun toggleRepo(repo: String) {
if (openRepo == repo) {
openRepo = null
return
}
openRepo = repo
repoFiles = LoadState.Loading
scope.launch {
repoFiles =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchRepoFiles(settings, machineId, repo))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
fun download(repo: String, file: String) = act {
startDownload(settings, machineId, repo, file)
}
fun cancel(key: String) = act { cancelDownload(settings, machineId, key) }
fun remove(key: String) = act { deleteModel(settings, machineId, key) }
}
/**
* One machine's models, asked for again while this screen is open.
*
* Polled rather than pushed: a download belongs to a machine, not to any session, so it has no
* event stream of its own. Faster while something is downloading, because that is the only thing
* here that changes by itself -- each ask is a round trip to that machine, and once a minute would
* be a progress bar that moved in jumps.
*
* [onLocalChange] fires when the set of models on the machine changes, which is how the screen
* around this learns that a download has become a model it must now draw settings for.
*
* [enabled] is false for a provider that holds no files of its own -- the Claude CLI names its
* models rather than storing them -- and then nothing is asked of the machine at all. Taken as a
* parameter rather than decided by the caller's `if`, so that this is composed unconditionally and
* keeps its search results across the moment the provider's kind arrives.
*/
@Composable
fun rememberMachineModels(
settings: ServerSettings,
machineId: String,
enabled: Boolean,
onLocalChange: () -> Unit,
): MachineModelsState {
val scope = rememberCoroutineScope()
val state = remember(settings, machineId) { MachineModelsState(settings, machineId, scope) }
LaunchedEffect(state, enabled) {
if (!enabled) return@LaunchedEffect
var known: List<String>? = null
while (true) {
state.reload()
val local = state.models?.local?.map { it.key }
if (local != null) {
if (known != null && known != local) onLocalChange()
known = local
}
delay(if (state.downloads.any { it.state == "running" }) 1500 else 5000)
}
}
return state
}
/** What is being fetched onto this machine, above the models it already has. */
fun LazyListScope.downloadCards(state: MachineModelsState) {
uniqueItems(state.downloads, key = { "download:" + it.key }) { download ->
DownloadCard(
download = download,
onCancel = { state.cancel(download.key) },
onResume = { state.download(download.repo, download.file) },
onRemove = { state.remove(download.key) },
)
}
}
/**
* Finding a model to fetch: a search, and what it found.
*
* Below the models this machine has rather than above them, because what is here is what the reader
* came for and getting another is the rarer errand.
*/
fun LazyListScope.modelSearch(state: MachineModelsState) {
item("search") {
Spacer(Modifier.height(16.dp))
Text("Get another model", style = MaterialTheme.typography.titleSmall)
Text(
"Downloaded onto this machine, which is where llama.cpp reads it from.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
val keyboard = LocalSoftwareKeyboardController.current
OutlinedTextField(
value = state.query,
onValueChange = { state.query = it },
label = { Text("Search HuggingFace") },
singleLine = true,
// The keyboard's own key searches, and puts itself away to show what it found. The
// button below this is under the keyboard while it is up, so without this the only
// way to press it is to dismiss the keyboard first -- which nothing on screen says.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions =
KeyboardActions(
onSearch = {
keyboard?.hide()
state.search()
}
),
modifier = Modifier.fillMaxWidth(),
)
TextButton(
enabled = state.query.isNotBlank(),
onClick = {
keyboard?.hide()
state.search()
},
) {
Text("Search")
}
}
when (val found = state.results) {
null -> {}
is LoadState.Loading -> item("searching") { CircularProgressIndicator() }
is LoadState.Error ->
item("search-failed") { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded ->
uniqueItems(found.value, key = { "repo:" + it.id }) { repo ->
val open = state.openRepo == repo.id
RepoRow(repo, expanded = open) { state.toggleRepo(repo.id) }
// Inside the expanded repository's own item rather than as a section after the
// list: drawn after every card, a repository's files read as belonging to
// whichever card happened to be last.
if (open) {
when (val files = state.repoFiles) {
null -> {}
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error ->
Text(files.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
Column {
val busy = state.downloads.map { it.key }.toSet()
files.value.forEach { file ->
RepoFileRow(
file,
downloading = "${repo.id}/${file.path}" in busy,
) {
state.download(repo.id, file.path)
}
}
}
}
}
}
}
}
@Composable
private fun DownloadCard(
download: Download,
onCancel: () -> Unit,
onResume: () -> Unit,
onRemove: () -> Unit,
) {
val running = download.state == "running" || download.state == "verifying"
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(download.file, style = MaterialTheme.typography.titleSmall)
Text(
download.repo,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. HuggingFace sends no size when it
// was never told one, and a bar drawn from a guess is worse than one that admits it
// is counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say
// the opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
Text(
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
style = MaterialTheme.typography.bodySmall,
)
} else if (running) {
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text(
"${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall,
)
}
download.error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
download.state,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
if (running) {
TextButton(onClick = onCancel) { Text("Cancel") }
} else {
// A stopped download kept its partial file, so carrying on is the cheap
// answer and starting again is not the only one offered.
TextButton(onClick = onResume) { Text("Resume") }
TextButton(onClick = onRemove) { Text("Remove") }
}
}
}
}
}
@Composable
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
"${repo.downloads} downloads · ${repo.likes} likes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
}
}
}
@Composable
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(file.path, style = MaterialTheme.typography.bodyMedium)
Text(
gigabytes(file.bytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
file.have -> "Downloaded"
downloading -> "Downloading"
else -> "Download"
}
)
}
}
}
fun gigabytes(bytes: Long): String =
if (bytes >= 1_000_000_000) {
"%.2f GB".format(bytes / 1_000_000_000.0)
} else {
"%.0f MB".format(bytes / 1_000_000.0)
}
@@ -1,5 +1,6 @@
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
@@ -26,8 +27,11 @@ 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
@@ -246,32 +250,50 @@ private fun MachineCard(
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, and the server holding them -- and those had nowhere to be until
// one llama-server came to serve every session on a machine.
// 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 = 2.dp).clickable {
onProvider(provider)
},
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
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(horizontal = 12.dp),
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
Text(provider.name, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.weight(1f))
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") }
}
Chevron(
Pointing.Right,
Modifier.padding(start = 4.dp).semantics {
contentDescription = "Settings for ${provider.name}"
},
)
}
}
}
@@ -26,18 +26,22 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
/**
* The app's root: one title, and four views of the backend behind it.
* The app's root: one title, and three views of the backend behind it.
*
* These were four screens reached by four words in a row under the title, and the row was already
* full. Tabs say the same thing in less space and say one more thing besides: that these are places
* to be rather than errands to run. Sessions, the machine's importable history, the models on it
* and the machines themselves are all *the same backend*, looked at four ways, and none is a step
* down from another. Settings still is, which is why it stays a pushed screen with its own Back.
* These were screens reached by words in a row under the title, and the row was already full. Tabs
* say the same thing in less space and say one more thing besides: that these are places to be
* rather than errands to run. Sessions, the machine's importable history and the machines
* themselves are all *the same backend*, looked at three ways, and none is a step down from
* another. Settings still is, which is why it stays a pushed screen with its own Back.
*
* Models were a fourth tab until 2026-09-19. They are a machine's models now -- downloaded onto the
* machine that has to serve them -- so they live under that machine's llama.cpp provider, beside
* the settings deciding how each one is loaded. A tab about "the models" was a claim that there is
* one such set, and there is one per machine.
*/
private enum class MainTab(val label: String) {
Sessions("Sessions"),
Import("Import"),
Models("Models"),
Machines("Machines"),
}
@@ -145,7 +149,6 @@ fun MainScreen(
)
MainTab.Import ->
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
MainTab.Machines ->
MachinesScreen(settings = settings, reloadToken = token, onProvider = onProvider)
}
@@ -1,374 +0,0 @@
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.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Models on the backend, and HuggingFace to get more from.
*
* Everything here is the server's state rather than this screen's: what is downloaded, and what is
* downloading, are the same answers on every enrolled device, and a download started here keeps
* going when this screen closes.
*/
@Composable
fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<Models>>(LoadState.Loading) }
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<LoadState<List<RemoteRepo>>?>(null) }
var openRepo by remember { mutableStateOf<String?>(null) }
var repoFiles by remember { mutableStateOf<LoadState<List<RemoteFile>>?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchModels(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// Polled rather than pushed: a download belongs to the machine, not to any session, so it has
// no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the
// loop with a read now rather than leaving the reader watching for a second and a half.
LaunchedEffect(reloadToken) {
while (true) {
reload()
delay(1500)
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search HuggingFace") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
TextButton(
enabled = query.isNotBlank(),
onClick = {
openRepo = null
results = LoadState.Loading
scope.launch {
results =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(searchModels(settings, query))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
},
) {
Text("Search")
}
Spacer(Modifier.height(8.dp))
LazyColumn(Modifier.fillMaxSize()) {
when (val current = state) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(current.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded -> {
if (current.value.downloads.isNotEmpty()) {
item { SectionLabel("Downloading") }
uniqueItems(current.value.downloads, key = { it.key + it.run }) { download
->
DownloadCard(download) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
cancelDownload(settings, download.key)
}
}
.exceptionOrNull()
?.message
}
}
}
}
item { SectionLabel("On the backend") }
if (current.value.local.isEmpty()) {
item {
Text(
"None yet. Search above to find one.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
uniqueItems(current.value.local, key = { it.key }) { model ->
LocalModelCard(model) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
deleteModel(settings, model.key)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
results?.let { found ->
item { SectionLabel("HuggingFace") }
when (found) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded ->
uniqueItems(found.value, key = { it.id }) { repo ->
val open = openRepo == repo.id
RepoRow(repo, expanded = open) {
if (open) {
openRepo = null
} else {
openRepo = repo.id
repoFiles = LoadState.Loading
scope.launch {
repoFiles =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(
fetchRepoFiles(settings, repo.id)
)
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
}
// Inside the expanded repository's own item rather than as a section
// after the list: drawn after every card, a repository's files read as
// belonging to whichever card happened to be last.
if (open) {
when (val files = repoFiles) {
null -> {}
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error ->
Text(files.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
Column {
val busy =
(state as? LoadState.Loaded)
?.value
?.downloads
.orEmpty()
.filter { it.state == "running" }
.map { it.key }
.toSet()
files.value.forEach { file ->
RepoFileRow(
file,
downloading = "${repo.id}/${file.path}" in busy,
) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
startDownload(
settings,
repo.id,
file.path,
)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
}
}
}
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Spacer(Modifier.height(12.dp))
Text(text, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(4.dp))
}
@Composable
private fun DownloadCard(download: Download, onCancel: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(download.file, style = MaterialTheme.typography.titleSmall)
Text(
download.repo,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. The server sends no total when it was
// never told one, and a bar drawn from a guess is worse than one that admits it is
// counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say the
// opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
Text(
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
style = MaterialTheme.typography.bodySmall,
)
} else {
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text(
"${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall,
)
}
download.error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row {
Text(
download.state,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
if (download.state == "running") {
TextButton(onClick = onCancel) { Text("Cancel") }
}
}
}
}
}
@Composable
private fun LocalModelCard(model: LocalModel, onDelete: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(model.file, style = MaterialTheme.typography.titleSmall)
Text(
"${model.repo} · ${gigabytes(model.bytes)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onDelete) { Text("Delete") }
}
}
}
@Composable
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
"${repo.downloads} downloads · ${repo.likes} likes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
}
}
}
@Composable
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(file.path, style = MaterialTheme.typography.bodyMedium)
Text(
gigabytes(file.bytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
file.have -> "Downloaded"
downloading -> "Downloading"
else -> "Download"
}
)
}
}
}
private fun gigabytes(bytes: Long): String =
if (bytes >= 1_000_000_000) {
"%.2f GB".format(bytes / 1_000_000_000.0)
} else {
"%.0f MB".format(bytes / 1_000_000.0)
}
@@ -68,6 +68,21 @@ fun ProviderScreen(
// 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 =
@@ -93,7 +108,9 @@ fun ProviderScreen(
Unit
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
// 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") }
}
@@ -163,10 +180,21 @@ fun ProviderScreen(
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 =
@@ -187,6 +215,7 @@ fun ProviderScreen(
enabled = busy == null,
)
}
if (kind == "llama_cpp") modelSearch(machineModels)
if (view.mcpServers.isNotEmpty()) {
item("mcp") {
Spacer(Modifier.height(12.dp))
@@ -219,6 +248,33 @@ fun ProviderScreen(
)
}
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 },
@@ -311,8 +367,11 @@ private fun ServerCard(
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(
@@ -321,7 +380,20 @@ private fun ModelCard(
.then(if (onEdit != null && enabled) Modifier.clickable(onClick = onEdit) else Modifier)
) {
Column(Modifier.padding(12.dp)) {
Text(model.label, style = MaterialTheme.typography.bodyMedium)
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 {
@@ -345,8 +417,15 @@ private fun ModelCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (onUnload != null) {
Row { TextButton(enabled = enabled, onClick = onUnload) { Text("Unload") } }
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") } }
}
}
}
}
@@ -1,10 +1,12 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -18,6 +20,14 @@ import androidx.compose.ui.unit.dp
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
* copies of "clip, fill, pad" drift apart the first time one is adjusted.
*
* **Nothing in here wraps; it scrolls sideways instead.** This is column-aligned far more often
* than it is prose -- a diff, a table, a test run, a command and its arguments -- and wrapping
* destroys exactly the alignment that was carrying the meaning, while turning one line into four
* and a run of them into a wall. The scroll belongs to the block rather than to each line so that
* the lines stay aligned with each other as it moves: one offset for the whole column is what makes
* a shifted diff still read as a diff. Every [Text] inside is therefore drawn with `softWrap =
* false`, which is the half of this a caller has to remember.
*
* The colour is [rawSurface], which is also what a code block inside a reply is given.
*/
@Composable
@@ -29,6 +39,10 @@ fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.()
// rectangle drawn at the same radius as the one behind it reads as a misprint.
.clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface)
// Clipped and filled before this, so the tint is the viewport and does not scroll away
// from under the text; padded after it, so the inset travels with the content and the
// last column does not end flush against the edge.
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 6.dp),
content = content,
)
@@ -1,9 +1,6 @@
package com.example.aiapp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -150,7 +147,8 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
RawBlock(modifier) {
parsed.subject?.let { subject ->
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely.
// one being read closely. The sideways scroll that makes that readable is the block's,
// shared with the lines below -- see [RawBlock].
Text(
// Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it.
@@ -158,7 +156,6 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
}
parsed.rest.forEach {
@@ -167,6 +164,7 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
modifier = Modifier.padding(top = 2.dp),
)
}
@@ -431,7 +431,8 @@ fun ToolCard(
// What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is
// prose, and a proportional font silently destroys the alignment that carried
// the meaning.
// the meaning. Unwrapped for the same reason, and scrolled sideways by the
// block around it -- see [RawBlock].
//
// Its terminal styling applied and the rest of the escapes taken out: colour is
// often the whole of what a diff or a test run is saying. Remembered against
@@ -443,6 +444,7 @@ fun ToolCard(
styled,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
)
}
}