Browse, download and delete models from the phone

The point of the llama.cpp work was that models are managed from the app,
not by editing the backend's filesystem, so this is the screen for it:
search HuggingFace, expand a repository to see its GGUFs with sizes,
download one and watch it, cancel it, delete what is no longer wanted.

Everything shown is the server's state rather than the screen's. A
download started here keeps going when the screen closes, is visible from
any enrolled device, and its outcome outlives it -- demonstrated by
accident while testing, when a 538 MB download finished during an app
rebuild and was still there, complete, after reinstalling.

Polled rather than streamed, at 1.5s. A download belongs to the machine
rather than to any session, so it has no event stream of its own; this is
the one screen in the app that asks repeatedly instead of being told.

Three things the screenshots decided rather than the diff:

- **The list header no longer squeezes its title.** Adding a fourth action
  to the row wrapped "AI Sessions" onto three lines. Title and actions now
  have a row each, so a fifth costs nothing and the title is never what
  gives.
- **A repository's files render inside its own card**, not as a section
  after the list -- drawn after every card they read as belonging to
  whichever was last.
- **A file already downloading says so** and is disabled, rather than
  offering a Download button whose effect nobody can see.

The progress bar is determinate only when the server reported a size, and
says "total size unknown" otherwise rather than inventing a position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-28 05:32:38 -04:00
1 parent 3be25c2f64
commit 7cf36005ae
4 files changed
+509 -6

No files matched your search

@@ -103,6 +103,9 @@ private fun <T> JSONArray.mapObjects(parse: (JSONObject) -> T): List<T> =
private fun JSONArray.strings(): List<String> = (0 until length()).map { getString(it) }
/** Percent-encodes a value going into a query string. */
private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name())
// One row of GET /sessions. `provider` is what runs it, `host` where --
// the two are independent, so a session names both.
data class SessionSummary(
@@ -294,3 +297,111 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
fun deleteSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
}
// Models: what this backend has downloaded, what it is downloading, and
// what HuggingFace offers. 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.
data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long)
/**
* A download in flight or finished. [total] is null when the server never said how big the file is
* -- which must render as "not known", never as a bar at some invented position.
*/
data class Download(
val key: String,
val run: Long,
val repo: String,
val file: String,
val state: String,
val done: Long,
val total: Long?,
val error: String?,
)
data class Models(val local: List<LocalModel>, val downloads: List<Download>)
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,
)
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"),
)
},
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(),
) {}
}