Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
e0eaa4b3f8
13 files changed
+581
-139
No files matched your search
@@ -150,9 +150,15 @@ data class SessionSummary(
|
||||
*/
|
||||
val notify: Boolean,
|
||||
/**
|
||||
* Every token this session has spent, as the server counts it -- see `SessionEvent.UsageDelta`.
|
||||
* How much context this session is holding, as the server last measured it -- see
|
||||
* `SessionEvent.UsageDelta`.
|
||||
*
|
||||
* Null where nothing has been measured: a session that has not run a turn, a provider that does
|
||||
* not report usage, or a clear nobody has run a turn since. That is not zero, and the status
|
||||
* row says so in words rather than drawing an empty context for a conversation that may be
|
||||
* nearly full.
|
||||
*/
|
||||
val totalTokens: Long,
|
||||
val contextTokens: Long?,
|
||||
/**
|
||||
* The longest edge an image should have when it reaches this session, or null where the
|
||||
* provider has no limit.
|
||||
@@ -178,7 +184,8 @@ private fun parseSession(session: JSONObject) =
|
||||
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
||||
imported = session.optBoolean("imported", false),
|
||||
notify = session.optBoolean("notify", true),
|
||||
totalTokens = session.optLong("totalTokens", 0),
|
||||
contextTokens =
|
||||
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
||||
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
||||
status = session.getString("status"),
|
||||
lastActivity = session.getDouble("lastActivity"),
|
||||
|
||||
@@ -37,7 +37,14 @@ fun compactionSummary(item: TranscriptItem.CompactedNote): String {
|
||||
}
|
||||
}
|
||||
|
||||
private fun tokens(count: Long): String = "%,d".format(count)
|
||||
/**
|
||||
* A token count as a reader reads one.
|
||||
*
|
||||
* Shared with the status row rather than formatted at each: the divider and the row report the same
|
||||
* quantity about the same moment, and one of them grouping its thousands while the other did not
|
||||
* read as two different measurements.
|
||||
*/
|
||||
fun tokens(count: Long): String = "%,d".format(count)
|
||||
|
||||
/**
|
||||
* What the working indicator says while a compaction is running.
|
||||
|
||||
@@ -113,14 +113,16 @@ sealed class SessionEvent {
|
||||
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
|
||||
|
||||
/**
|
||||
* What a turn cost, and what the session has cost in total.
|
||||
* What a turn cost, and how much the model was holding when it ended.
|
||||
*
|
||||
* [total] is the server's running figure, carried on the event so a reader never adds up its
|
||||
* own: a phone opens a session on the newest page of the transcript, so a sum it computed would
|
||||
* be that page's share of the conversation wearing the whole conversation's label. Zero on
|
||||
* entries recorded before the backend sent it.
|
||||
* [context] is prompt plus both cache figures, measured by the backend from the turn's own
|
||||
* usage. Carried on the event rather than summed by the reader, because it is not a sum: a
|
||||
* conversation's context drops at a compaction and a clear, so adding turns up would report a
|
||||
* figure the session stopped being true of. Null where the dialect did not say, and on entries
|
||||
* recorded before the backend sent it -- which leaves the context unmeasured rather than
|
||||
* unchanged.
|
||||
*/
|
||||
data class UsageDelta(val tokens: Long, val total: Long) : SessionEvent()
|
||||
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
|
||||
|
||||
/**
|
||||
* A compaction that finished, and how much context it recovered.
|
||||
@@ -235,7 +237,10 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
permissionMode = body.optString("permissionMode").ifEmpty { null },
|
||||
)
|
||||
"usageDelta" ->
|
||||
SessionEvent.UsageDelta(body.getLong("tokens"), body.optLong("total", 0))
|
||||
SessionEvent.UsageDelta(
|
||||
body.getLong("tokens"),
|
||||
if (body.has("context")) body.getLong("context") else null,
|
||||
)
|
||||
"compacted" ->
|
||||
SessionEvent.Compacted(
|
||||
preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null,
|
||||
@@ -248,3 +253,29 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
}
|
||||
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
|
||||
}
|
||||
|
||||
/**
|
||||
* The context after [event], given what it was before.
|
||||
*
|
||||
* The same rule the server folds with, because the screen has to keep up between page loads: the
|
||||
* summary it opened with is a measurement from before this stream started, and every event that
|
||||
* moves the figure arrives here.
|
||||
*
|
||||
* The two that lower it are the point. A clear takes the conversation away and a compaction
|
||||
* replaces it with a summary, so a figure measured before either stopped being true at that moment
|
||||
* -- and carrying it forward is how a session that had just been cleared went on reporting the
|
||||
* context it no longer had.
|
||||
*
|
||||
* Null is "we don't know", which is a state each of them can reach: nothing measured yet, a
|
||||
* compaction that finished without saying how much it recovered, or a clear nobody has run a turn
|
||||
* since.
|
||||
*/
|
||||
fun contextAfter(current: Long?, event: SessionEvent): Long? =
|
||||
when (event) {
|
||||
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a
|
||||
// turn -- which every context figure is -- rather than unknown.
|
||||
is SessionEvent.UsageDelta -> event.context ?: current
|
||||
is SessionEvent.Compacted -> event.postTokens
|
||||
is SessionEvent.Cleared -> null
|
||||
else -> current
|
||||
}
|
||||
@@ -216,9 +216,20 @@ sealed class TranscriptItem {
|
||||
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
|
||||
* keeps whatever it was called when it started, however many calls arrive at either end of it
|
||||
* afterwards.
|
||||
*
|
||||
* A question to the reader is in a run of its own, which is what puts it on the transcript as a row
|
||||
* rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is
|
||||
* always visible, since a run of one is drawn as itself rather than as a group; and the calls
|
||||
* around it fall into a group before it and a group after it, so where the reader was asked
|
||||
* something is legible in the shape of the transcript without opening anything. It ends the run
|
||||
* before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in
|
||||
* the work, not a gap in the middle of one run.
|
||||
*/
|
||||
private fun runIdFor(items: List<TranscriptItem>, id: String): String =
|
||||
(items.lastOrNull() as? TranscriptItem.ToolRun)?.runId ?: id
|
||||
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
|
||||
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
|
||||
if (tool == ASK_USER_QUESTION || previous.tool == ASK_USER_QUESTION) return id
|
||||
return previous.runId
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a page of older items in front of the ones already loaded, healing whatever the page
|
||||
@@ -311,8 +322,15 @@ private fun adoptRun(
|
||||
earlier: List<TranscriptItem>,
|
||||
later: List<TranscriptItem>,
|
||||
): List<TranscriptItem> {
|
||||
val joining = (later.firstOrNull() as? TranscriptItem.ToolRun)?.runId ?: return earlier
|
||||
val tail = earlier.takeLastWhile { it is TranscriptItem.ToolRun }
|
||||
val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier
|
||||
// A question is in a run of its own on both sides of the join, the same as it would be had
|
||||
// the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a
|
||||
// group straight through the row the reader was asked something on.
|
||||
if (first.tool == ASK_USER_QUESTION) return earlier
|
||||
val joining = first.runId
|
||||
val tail = earlier.takeLastWhile {
|
||||
it is TranscriptItem.ToolRun && it.tool != ASK_USER_QUESTION
|
||||
}
|
||||
if (tail.isEmpty()) return earlier
|
||||
return earlier.dropLast(tail.size) +
|
||||
tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) }
|
||||
@@ -338,7 +356,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
TranscriptItem.ToolRun(
|
||||
entry.seq,
|
||||
event.id,
|
||||
runIdFor(items, event.id),
|
||||
runIdFor(items, event.id, event.tool),
|
||||
event.tool,
|
||||
event.input,
|
||||
"",
|
||||
@@ -359,7 +377,10 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
TranscriptItem.ToolRun(
|
||||
entry.seq,
|
||||
event.id,
|
||||
runIdFor(items, event.id),
|
||||
// The name is not known from an end alone, so a call that was an ask
|
||||
// cannot be recognised as one here; loading the page before this
|
||||
// replaces the row with the real thing, which is when it splits out.
|
||||
runIdFor(items, event.id, "tool"),
|
||||
"tool",
|
||||
"",
|
||||
event.output,
|
||||
@@ -458,10 +479,10 @@ fun SessionScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
||||
var status by remember { mutableStateOf(summary.status) }
|
||||
// Seeded from the row this screen was opened from, so a conversation that has spent
|
||||
// something says so before any turn happens here. Zero used to mean "nothing yet" and
|
||||
// "nothing in the page I loaded" at once, and the second one is most of the long sessions.
|
||||
var totalTokens by remember(summary.id) { mutableLongStateOf(summary.totalTokens) }
|
||||
// Seeded from the row this screen was opened from, so a conversation already under way says
|
||||
// how much it is holding before any turn happens here. Null is "nobody has measured it",
|
||||
// which is a different answer from an empty context and is drawn differently.
|
||||
var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) }
|
||||
// When this screen saw the current compaction start, on this device's own clock, and how long
|
||||
// ago that is. See `compactingLabel`: null is the honest answer whenever the start was not
|
||||
// witnessed here, which is what opening a session that is already compacting looks like.
|
||||
@@ -612,14 +633,14 @@ fun SessionScreen(
|
||||
*/
|
||||
fun apply(entry: SeqEvent) {
|
||||
lastSeq.set(entry.seq)
|
||||
// Before the rest, and for every event rather than only the usage ones: a compaction and
|
||||
// a clear move this as much as a turn does, which is the whole reason it is a fold and
|
||||
// not a running total. See `contextAfter`.
|
||||
contextTokens = contextAfter(contextTokens, entry.event)
|
||||
when (val event = entry.event) {
|
||||
// Taken, not accumulated: the server's running total is on the event, and adding
|
||||
// up the deltas this screen happened to receive counted one page of a conversation
|
||||
// and called it the whole. `max` because pages arrive in no guaranteed order and an
|
||||
// older event's total is a smaller true answer, never a correction downwards; it
|
||||
// also leaves the seeded figure alone for transcripts recorded before the backend
|
||||
// sent a total at all.
|
||||
is SessionEvent.UsageDelta -> totalTokens = maxOf(totalTokens, event.total)
|
||||
// Nothing further: what it carries was folded into the context above, and what a
|
||||
// turn cost is not something the transcript draws.
|
||||
is SessionEvent.UsageDelta -> {}
|
||||
else -> {
|
||||
// What the session says it is set to now, which is the only thing that
|
||||
// says it: picking from either menu asks, and the answer comes back here.
|
||||
@@ -1230,7 +1251,11 @@ fun SessionScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SessionStatusRow(status = status, compactingFor = compactingFor, totalTokens = totalTokens)
|
||||
SessionStatusRow(
|
||||
status = status,
|
||||
compactingFor = compactingFor,
|
||||
contextTokens = contextTokens,
|
||||
)
|
||||
|
||||
// Between the transcript and the box: above what is being typed, so the list does not
|
||||
// cover the thing the command is about, and below everything that explains it.
|
||||
@@ -1488,7 +1513,8 @@ private fun SessionStatusRow(
|
||||
status: String,
|
||||
/** Seconds since this device saw the compaction start; null if it did not see it. */
|
||||
compactingFor: Long?,
|
||||
totalTokens: Long,
|
||||
/** Context the session is holding, or null where nothing has measured it. */
|
||||
contextTokens: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
@@ -1558,15 +1584,19 @@ private fun SessionStatusRow(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
// Nothing rather than "0 tok" before anything has been spent: a total of zero is a fact
|
||||
// about a conversation that has not started, and it is the one reading nobody needs.
|
||||
if (totalTokens > 0) {
|
||||
Text(
|
||||
"$totalTokens tok",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// How full the session is, which is the number a reader is asking about -- how much room
|
||||
// is left before the next compaction -- rather than what has been spent getting here.
|
||||
//
|
||||
// "unknown" in words, and always drawn. A context nobody has measured is not an empty
|
||||
// one, and the two used to share an appearance: a session that had just been cleared, one
|
||||
// whose provider never reports usage, and one that has not run a turn all showed nothing
|
||||
// at all, which reads as a conversation with room to spare. It is the same reason the
|
||||
// status word beside it names the quiet state instead of leaving the row blank.
|
||||
Text(
|
||||
contextTokens?.let { "context ${tokens(it)}" } ?: "context unknown",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -296,5 +296,10 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List<Stri
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool whose input is a question rather than a command; see [AskUserQuestionBody]. */
|
||||
private const val ASK_USER_QUESTION = "AskUserQuestion"
|
||||
/**
|
||||
* The tool whose input is a question rather than a command; see [AskUserQuestionBody].
|
||||
*
|
||||
* Also what [runIdFor] breaks a run of calls on, so the row a reader answered is never folded
|
||||
* inside a collapsed group.
|
||||
*/
|
||||
const val ASK_USER_QUESTION = "AskUserQuestion"
|
||||
Reference in new issue
Block a user