diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 8f2230e..c16bd21 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -103,6 +103,9 @@ private fun JSONArray.mapObjects(parse: (JSONObject) -> T): List = private fun JSONArray.strings(): List = (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, val downloads: List) + +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 = + 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 = + 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(), + ) {} +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index d3fcd9f..db94eb7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -22,6 +22,8 @@ private sealed class Screen { data object Usage : Screen() + data object Models : Screen() + data object Settings : Screen() } @@ -73,6 +75,7 @@ fun AppRoot(settingsVersion: Int) { onOpen = { screen = Screen.Session(it) }, onSpawn = { screen = Screen.Spawn }, onUsage = { screen = Screen.Usage }, + onModels = { screen = Screen.Models }, onSettings = { screen = Screen.Settings }, ) is Screen.Session -> @@ -91,6 +94,7 @@ fun AppRoot(settingsVersion: Int) { onBack = goToList, ) is Screen.Usage -> UsageScreen(settings = current, onBack = goToList) + is Screen.Models -> ModelsScreen(settings = current, onBack = goToList) is Screen.Settings -> SettingsScreen( existing = current, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt new file mode 100644 index 0000000..263a09d --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt @@ -0,0 +1,384 @@ +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.foundation.lazy.items +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, onBack: () -> Unit) { + val scope = rememberCoroutineScope() + var state by remember { mutableStateOf>(LoadState.Loading) } + var query by remember { mutableStateOf("") } + var results by remember { mutableStateOf>?>(null) } + var openRepo by remember { mutableStateOf(null) } + var repoFiles by remember { mutableStateOf>?>(null) } + var actionError by remember { mutableStateOf(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. Slow enough not + // to matter, frequent enough that a bar moves. + LaunchedEffect(Unit) { + while (true) { + reload() + delay(1500) + } + } + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + "Models", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onBack) { Text("Back") } + } + Spacer(Modifier.height(8.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") } + items(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, + ) + } + } + items(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 -> + items(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() }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + "${gigabytes(download.done)} of ${gigabytes(download.total)}", + style = MaterialTheme.typography.bodySmall, + ) + } else { + LinearProgressIndicator(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 -- the server joins the running + // download rather than starting a second. + 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) + } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index c34113f..56473dd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -51,6 +51,7 @@ fun SessionListScreen( onOpen: (SessionSummary) -> Unit, onSpawn: () -> Unit, onUsage: () -> Unit, + onModels: () -> Unit, onSettings: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -87,20 +88,23 @@ fun SessionListScreen( Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize().padding(16.dp)) { + // Title and actions on separate rows. Sharing one row worked + // with three actions and broke with the fourth: the title took + // whatever was left and wrapped "AI Sessions" onto three + // lines. Giving the actions their own row means adding a fifth + // costs nothing, and the title is never the thing that gives. + Text("AI Sessions", style = MaterialTheme.typography.headlineSmall) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - Text( - "AI Sessions", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), - ) TextButton(onClick = onUsage) { Text("Usage") } + TextButton(onClick = onModels) { Text("Models") } TextButton(onClick = onSettings) { Text("Settings") } + Spacer(Modifier.weight(1f)) TextButton(onClick = { refresh() }) { Text("Refresh") } } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(8.dp)) when (val state = listState) { is LoadState.Loading -> CircularProgressIndicator()