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:
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) }
|
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 --
|
// One row of GET /sessions. `provider` is what runs it, `host` where --
|
||||||
// the two are independent, so a session names both.
|
// the two are independent, so a session names both.
|
||||||
data class SessionSummary(
|
data class SessionSummary(
|
||||||
@@ -294,3 +297,111 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
|
|||||||
fun deleteSession(settings: ServerSettings, sessionId: String) {
|
fun deleteSession(settings: ServerSettings, sessionId: String) {
|
||||||
requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
|
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(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ private sealed class Screen {
|
|||||||
|
|
||||||
data object Usage : Screen()
|
data object Usage : Screen()
|
||||||
|
|
||||||
|
data object Models : Screen()
|
||||||
|
|
||||||
data object Settings : Screen()
|
data object Settings : Screen()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +75,7 @@ fun AppRoot(settingsVersion: Int) {
|
|||||||
onOpen = { screen = Screen.Session(it) },
|
onOpen = { screen = Screen.Session(it) },
|
||||||
onSpawn = { screen = Screen.Spawn },
|
onSpawn = { screen = Screen.Spawn },
|
||||||
onUsage = { screen = Screen.Usage },
|
onUsage = { screen = Screen.Usage },
|
||||||
|
onModels = { screen = Screen.Models },
|
||||||
onSettings = { screen = Screen.Settings },
|
onSettings = { screen = Screen.Settings },
|
||||||
)
|
)
|
||||||
is Screen.Session ->
|
is Screen.Session ->
|
||||||
@@ -91,6 +94,7 @@ fun AppRoot(settingsVersion: Int) {
|
|||||||
onBack = goToList,
|
onBack = goToList,
|
||||||
)
|
)
|
||||||
is Screen.Usage -> UsageScreen(settings = current, onBack = goToList)
|
is Screen.Usage -> UsageScreen(settings = current, onBack = goToList)
|
||||||
|
is Screen.Models -> ModelsScreen(settings = current, onBack = goToList)
|
||||||
is Screen.Settings ->
|
is Screen.Settings ->
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
existing = current,
|
existing = current,
|
||||||
|
|||||||
@@ -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<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. 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)
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ fun SessionListScreen(
|
|||||||
onOpen: (SessionSummary) -> Unit,
|
onOpen: (SessionSummary) -> Unit,
|
||||||
onSpawn: () -> Unit,
|
onSpawn: () -> Unit,
|
||||||
onUsage: () -> Unit,
|
onUsage: () -> Unit,
|
||||||
|
onModels: () -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -87,20 +88,23 @@ fun SessionListScreen(
|
|||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
"AI Sessions",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
TextButton(onClick = onUsage) { Text("Usage") }
|
TextButton(onClick = onUsage) { Text("Usage") }
|
||||||
|
TextButton(onClick = onModels) { Text("Models") }
|
||||||
TextButton(onClick = onSettings) { Text("Settings") }
|
TextButton(onClick = onSettings) { Text("Settings") }
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
TextButton(onClick = { refresh() }) { Text("Refresh") }
|
TextButton(onClick = { refresh() }) { Text("Refresh") }
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
when (val state = listState) {
|
when (val state = listState) {
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
is LoadState.Loading -> CircularProgressIndicator()
|
||||||
|
|||||||
Reference in new issue
Block a user