Merge branch 'main' of git.arirex.me:iris/ai-app
# Conflicts: # AGENTS.md # PLAN.md # app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt # app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt # server/src/config.rs # server/src/main.rs # server/src/routes.rs # server/src/session/echo.rs # server/src/session/llama.rs # server/src/session/transport.rs # server/src/ssh.rs # server/src/usage.rs
This commit is contained in:
commit
3c0214ece8
94 files changed
+8299
-9454
No files matched your search
@@ -34,20 +34,19 @@ sealed class SessionUsage {
|
||||
/**
|
||||
* This machine meters nothing, so there is no window to show.
|
||||
*
|
||||
* Separate from [Unavailable], and the distinction is the whole point: a session on `echo` or
|
||||
* on a local llama.cpp has no paid quota at all, which is a fact about how it was set up and
|
||||
* not a failure to find something out. The backend never asks such a machine, so it returns no
|
||||
* snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of
|
||||
* answering with the nearest available word. Drawn as nothing, because there is nothing.
|
||||
* Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a
|
||||
* local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a
|
||||
* failure to find something out. The backend never asks such a machine, and reading that
|
||||
* silence as "couldn't find out" is answering with the nearest available word.
|
||||
*/
|
||||
data object NotMetered : SessionUsage()
|
||||
|
||||
/**
|
||||
* The question could not be answered, and why.
|
||||
*
|
||||
* Its own state because "we couldn't find out" and "none of it is used" are the pair that must
|
||||
* never share an appearance: a bar sitting at zero because a machine is unreachable reads as
|
||||
* plenty of headroom, which is the opposite of the truth.
|
||||
* Its own state because "we couldn't find out" and "none of it is used" must never share an
|
||||
* appearance: a bar sitting at zero because a machine is unreachable reads as plenty of
|
||||
* headroom.
|
||||
*/
|
||||
data class Unavailable(val why: String) : SessionUsage()
|
||||
}
|
||||
@@ -59,17 +58,14 @@ private const val REFRESH_MS = 60_000L
|
||||
* One poll of every machine's limits, and the handle to ask again.
|
||||
*
|
||||
* A screen shows this answer in more than one place -- the bar under the session header, the colour
|
||||
* of the button beside it, and the dialog that button opens -- and each of those used to fetch for
|
||||
* itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole
|
||||
* refresh interval old when the dialog opens with a fresh one, so the header read 42% while the
|
||||
* screen over it read 47%, about a number somebody is deciding on. One feed per screen, and
|
||||
* [refresh] moves both.
|
||||
* of the button beside it, and the dialog that button opens -- and each used to fetch for itself.
|
||||
* Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval
|
||||
* old when the dialog opens with a fresh one, so the header read 42% while the screen over it read
|
||||
* 47%.
|
||||
*/
|
||||
class UsageFeed(
|
||||
val snapshots: LoadState<List<UsageSnapshot>>,
|
||||
/**
|
||||
* A fetch is outstanding. Only ever true over an answer already shown; see [rememberUsageFeed].
|
||||
*/
|
||||
/** A fetch is outstanding. Only ever true over an answer already shown. */
|
||||
val refreshing: Boolean,
|
||||
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
||||
val refresh: () -> Unit,
|
||||
@@ -102,15 +98,15 @@ class UsageFeed(
|
||||
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
|
||||
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
|
||||
var refreshing by remember { mutableStateOf(true) }
|
||||
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh
|
||||
// also resets the countdown to the next one rather than leaving one due immediately after.
|
||||
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also
|
||||
// resets the countdown rather than leaving one due immediately after.
|
||||
var asked by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(asked) {
|
||||
while (true) {
|
||||
refreshing = true
|
||||
// Replaces the answer only once the next one is in hand: dropping back to Loading
|
||||
// would blank a bar somebody is reading for the length of a round trip, and what was
|
||||
// on screen is still the last thing the machine actually said.
|
||||
// Replaces the answer only once the next one is in hand: dropping back to Loading would
|
||||
// blank a bar somebody is reading for the length of a round trip, and what was on
|
||||
// screen is still the last thing the machine actually said.
|
||||
snapshots =
|
||||
try {
|
||||
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
|
||||
@@ -130,13 +126,11 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
|
||||
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
|
||||
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
|
||||
* Taken over however many windows came back rather than the three Claude sends today -- the backend
|
||||
* deliberately passes windows it does not recognise straight through, so a fourth one is a thing
|
||||
* that happens rather than a thing to notice later.
|
||||
* passes windows it does not recognise straight through.
|
||||
*
|
||||
* Every state that is not a measurement takes the ordinary control colour instead. That is the
|
||||
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
|
||||
* unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind
|
||||
* the button is where those say, in words, which one they are.
|
||||
* unknown blue would say "measured, and fine" about a machine nobody could reach.
|
||||
*/
|
||||
@Composable
|
||||
fun usageGlyphColour(usage: SessionUsage): Color =
|
||||
@@ -154,18 +148,17 @@ fun usageGlyphColour(usage: SessionUsage): Color =
|
||||
* going, and it was a screen away from the place that decision gets made. It reports on this
|
||||
* session's machine alone -- the dialog is still where every machine is compared.
|
||||
*
|
||||
* What it shows is the paid service's own metering, fetched from the machine that holds the
|
||||
* account. It is never derived from what this app has watched go past: the transcript's token
|
||||
* counts are a different quantity, measured differently, and a bar shaped like a quota gauge built
|
||||
* out of them would be a guess wearing a measurement's clothes.
|
||||
* What it shows is the paid service's own metering, never derived from what this app has watched go
|
||||
* past: the transcript's token counts are a different quantity, measured differently, and a bar
|
||||
* built out of them would be a guess wearing a measurement's clothes.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
||||
DebugStats.count("usage bar recomposed")
|
||||
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
|
||||
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota
|
||||
// happened to move would sit at a stale figure for hours.
|
||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
|
||||
// would sit at a stale figure for hours.
|
||||
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
@@ -174,15 +167,14 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing at all for a session that meters nothing: a row saying "unknown" there would
|
||||
// report a problem about a setup somebody chose, on every screen, forever.
|
||||
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
|
||||
// a problem about a setup somebody chose, on every screen, forever.
|
||||
//
|
||||
// And nothing while the first fetch is out, which is not the same kind of silence. A
|
||||
// request in flight is not a state to report -- and the session that meters nothing is
|
||||
// exactly the one this cannot yet tell apart, so "5-hour usage: checking" appeared under
|
||||
// an echo session for half a second and was then taken away. A row that has to be
|
||||
// withdrawn is worse than one that arrives late, and this is the only state here whose
|
||||
// wrongness is a matter of timing rather than of fact.
|
||||
// And nothing while the first fetch is out, which is a different silence. A request in flight
|
||||
// is not a state to report -- and the session that meters nothing is exactly the one this
|
||||
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
|
||||
// second and was then taken away. A row that has to be withdrawn is worse than one that
|
||||
// arrives late.
|
||||
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
|
||||
return
|
||||
}
|
||||
@@ -239,9 +231,8 @@ private fun UsageNote(text: String) {
|
||||
* The percentage on its own does not answer the question it gets asked, which is whether to start
|
||||
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
|
||||
*
|
||||
* The window's end has two missing cases and they are worded differently on purpose; see
|
||||
* [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is
|
||||
* no countdown to report and inventing one would be the same fault as inventing the number.
|
||||
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
|
||||
* that is not running gets the percentage and nothing else.
|
||||
*/
|
||||
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
||||
val percent = "${window.percent.toInt()}%"
|
||||
@@ -265,8 +256,7 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
||||
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
|
||||
* service on one machine.
|
||||
*
|
||||
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it:
|
||||
* a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
|
||||
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
|
||||
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
||||
* machine having no quota rather than the question going unanswered.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user