Merge remote-tracking branch 'origin/main'
# Conflicts: # app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt
This commit is contained in:
commit
da65c1571f
3 files changed
+123
-37
No files matched your search
@@ -2,6 +2,7 @@ package com.example.aiapp
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -10,6 +11,7 @@ import androidx.compose.ui.unit.TextUnit
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.parseMarkdown
|
||||
|
||||
/**
|
||||
* An assistant's reply, rendered as the markdown it is written in.
|
||||
@@ -24,8 +26,22 @@ import com.mikepenz.markdown.m3.markdownTypography
|
||||
@Composable
|
||||
fun MarkdownText(text: String, modifier: Modifier = Modifier) {
|
||||
val body = MaterialTheme.typography.bodyLarge
|
||||
// Parsed here, in the composition, rather than by the overload that takes the text itself.
|
||||
// That one parses in a coroutine and draws an empty loading slot until the result arrives --
|
||||
// so a row is measured at nothing before it is measured at its real height, and the
|
||||
// transcript above it collapses and springs back. Seen with five replies on screen at once,
|
||||
// every one of them blank, the whole conversation shrunk to fit a single screen; a moment
|
||||
// later it was all there again. That is the "skipping up and down" this list must never do,
|
||||
// and no amount of scroll anchoring can survive a row that lies about its height first.
|
||||
//
|
||||
// The cost is the parse on the main thread, which is the trade being made deliberately: a
|
||||
// few milliseconds of work at the moment a row is composed, against a layout that is wrong
|
||||
// every time one is. If a very long reply ever makes that visible, the answer is the
|
||||
// renderer's streaming state -- which parses incrementally -- and not going back to a
|
||||
// placeholder with no height.
|
||||
val parsed = remember(text) { parseMarkdown(text) }
|
||||
Markdown(
|
||||
content = text,
|
||||
parsed,
|
||||
colors =
|
||||
markdownColor(
|
||||
text = MaterialTheme.colorScheme.onSurface,
|
||||
|
||||
@@ -489,12 +489,41 @@ fun SessionScreen(
|
||||
var loadingHistory by remember { mutableStateOf(false) }
|
||||
var ready by remember { mutableStateOf(false) }
|
||||
val listState = rememberLazyListState()
|
||||
// Whether the newest message is on screen right now. The list is laid out from the bottom
|
||||
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
|
||||
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
|
||||
//
|
||||
// It is also the gate on everything the list draws -- see [record].
|
||||
val atNewest by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
}
|
||||
// Transcript events that arrived while somebody was reading further back, in the order they
|
||||
// arrived, waiting for them to return to the newest end. See [record] for why.
|
||||
var held by remember { mutableStateOf(listOf<SeqEvent>()) }
|
||||
// What is actually drawn: the transcript with runs of adjacent tool
|
||||
// calls folded into one row each.
|
||||
val rows = remember(items) { groupToolRuns(items) }
|
||||
|
||||
fun apply(entry: SeqEvent) {
|
||||
lastSeq.set(entry.seq)
|
||||
/**
|
||||
* Everything the transcript list draws, from one event.
|
||||
*
|
||||
* Separate from [apply] because it is the half that is allowed to wait. The list anchors on the
|
||||
* leading edge of its first visible item, which in this upside-down layout is that item's
|
||||
* *bottom* -- so a row that grows pushes everything already on screen upwards, and the view
|
||||
* travels toward the newest end without anybody scrolling. Measured against a reply streamed in
|
||||
* four hundred pieces: scrolling back a screen and then waiting six seconds ended at the very
|
||||
* bottom, forty lines further on than where it was left.
|
||||
*
|
||||
* Insertions were never the problem -- the list is keyed, so a row arriving at either end
|
||||
* leaves the anchor where it is, and reading back through history while a session works has
|
||||
* always been still. What cannot be allowed is a row that is already there changing height, and
|
||||
* the one guarantee that covers every way that happens -- a reply streaming, a tool's output
|
||||
* arriving, a queued bubble appearing above the anchor -- is to change nothing at all while
|
||||
* somebody is reading further back.
|
||||
*/
|
||||
fun record(entry: SeqEvent) {
|
||||
// The oldest event this view holds, which is what paging backwards
|
||||
// starts from. Maintained here rather than by each loader: the
|
||||
// first page and a stream reset both begin an empty view, and one
|
||||
@@ -503,6 +532,44 @@ fun SessionScreen(
|
||||
oldestSeq = entry.seq
|
||||
moreHistory = entry.seq > 1L
|
||||
}
|
||||
val event = entry.event
|
||||
// The message coming back is the session saying it has
|
||||
// read it, so the bubble held below the indicator becomes
|
||||
// the row `foldEvent` is about to add.
|
||||
// Waiting, then read. Matched by id: the same message sent twice is two
|
||||
// bubbles, and clearing by text would take away whichever matched first.
|
||||
if (event is SessionEvent.MessageQueued) {
|
||||
queued = queued + QueuedMessage(event.id, event.text, event.images)
|
||||
}
|
||||
if (event is SessionEvent.UserMessage) {
|
||||
queued = queued.filterNot { it.id == event.id }
|
||||
}
|
||||
// Waiting, then gone: a command leaves this list when the session takes it,
|
||||
// and the row it becomes is added by `foldEvent` in the same pass.
|
||||
if (event is SessionEvent.CommandQueued) {
|
||||
waitingCommands = waitingCommands + (event.id to event.text)
|
||||
}
|
||||
if (event is SessionEvent.CommandSent) {
|
||||
waitingCommands = waitingCommands.filterNot { it.first == event.id }
|
||||
}
|
||||
// Kept as well as folded. Folding is one-way -- a tool's
|
||||
// start and end become one row -- so a page arriving in
|
||||
// front of what is already here cannot be stitched on
|
||||
// without the events themselves.
|
||||
loaded = loaded + event
|
||||
items = foldEvent(items, entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* One event, at the moment it arrives.
|
||||
*
|
||||
* What it says about the *session* -- running or not, which model, how many tokens -- lands
|
||||
* immediately, because none of that is drawn in the list and freezing it would trade a
|
||||
* transcript that jumps for a status row that lies. What it adds to the transcript goes through
|
||||
* [record], which waits for the reader to be at the newest end.
|
||||
*/
|
||||
fun apply(entry: SeqEvent) {
|
||||
lastSeq.set(entry.seq)
|
||||
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
|
||||
@@ -532,34 +599,9 @@ fun SessionScreen(
|
||||
}
|
||||
status = event.state
|
||||
}
|
||||
// The message coming back is the session saying it has
|
||||
// read it, so the bubble held below the indicator becomes
|
||||
// the row `foldEvent` is about to add. Matched by text --
|
||||
// all that distinguishes one message from an identical
|
||||
// earlier one -- and only the first match, so two
|
||||
// identical messages wait twice.
|
||||
// Waiting, then read. Matched by id: the same message sent twice is two
|
||||
// bubbles, and clearing by text would take away whichever matched first.
|
||||
if (event is SessionEvent.MessageQueued) {
|
||||
queued = queued + QueuedMessage(event.id, event.text, event.images)
|
||||
}
|
||||
if (event is SessionEvent.UserMessage) {
|
||||
queued = queued.filterNot { it.id == event.id }
|
||||
}
|
||||
// Waiting, then gone: a command leaves this list when the session takes it,
|
||||
// and the row it becomes is added by `foldEvent` in the same pass.
|
||||
if (event is SessionEvent.CommandQueued) {
|
||||
waitingCommands = waitingCommands + (event.id to event.text)
|
||||
}
|
||||
if (event is SessionEvent.CommandSent) {
|
||||
waitingCommands = waitingCommands.filterNot { it.first == event.id }
|
||||
}
|
||||
// Kept as well as folded. Folding is one-way -- a tool's
|
||||
// start and end become one row -- so a page arriving in
|
||||
// front of what is already here cannot be stitched on
|
||||
// without the events themselves.
|
||||
loaded = loaded + event
|
||||
items = foldEvent(items, entry)
|
||||
// In order, always: one late event recorded ahead of the backlog would fold a
|
||||
// streamed delta into whatever row happened to be last by then.
|
||||
if (atNewest && held.isEmpty()) record(entry) else held = held + entry
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -632,6 +674,7 @@ fun SessionScreen(
|
||||
// up pages the rest back in as it always does.
|
||||
items = listOf()
|
||||
loaded = listOf()
|
||||
held = listOf()
|
||||
oldestSeq = 0L
|
||||
moreHistory = true
|
||||
},
|
||||
@@ -659,12 +702,16 @@ fun SessionScreen(
|
||||
// stays started.
|
||||
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
||||
|
||||
// Whether the newest message is on screen right now. The list is laid out from the bottom
|
||||
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
|
||||
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
|
||||
val atNewest by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather
|
||||
// than paced out: they are at the bottom, which is the one place the list is allowed to
|
||||
// follow new content, and drip-feeding it would only make that following last longer.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { atNewest && held.isNotEmpty() }
|
||||
.collect { due ->
|
||||
if (!due) return@collect
|
||||
val backlog = held
|
||||
held = listOf()
|
||||
backlog.forEach { record(it) }
|
||||
}
|
||||
}
|
||||
// Whether they *chose* to be there, which is a different question and the one that decides
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
//! This is exactly the event vocabulary the real drivers produce, so a UI
|
||||
//! that renders echo sessions correctly renders the real thing.
|
||||
//!
|
||||
//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the
|
||||
//! shape a real model's reply arrives in, and the one where the row a
|
||||
//! reader is anchored to is the row that keeps changing height.
|
||||
//! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of
|
||||
//! different lengths, single tool calls, runs of adjacent ones, images
|
||||
//! and a peer message. Rows of every shape and height the app draws, in
|
||||
@@ -338,6 +341,9 @@ impl EchoDriver {
|
||||
// rather than trusted: this is a test affordance, and a session
|
||||
// pinned running for an hour by a typo is a worse outcome than a
|
||||
// short wait.
|
||||
let stream = text
|
||||
.strip_prefix("/stream")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(400).clamp(1, 4000));
|
||||
let mixed = text
|
||||
.strip_prefix("/mixed")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
|
||||
@@ -423,6 +429,23 @@ impl EchoDriver {
|
||||
return;
|
||||
}
|
||||
|
||||
// One long answer arriving in small pieces, which is what a real
|
||||
// model does and what `/slow` does not: `/slow` emits a line a
|
||||
// second, so its message grows in steps a reader can watch one
|
||||
// at a time. A jump caused by the *anchor row itself* changing
|
||||
// height needs growth that is continuous.
|
||||
if let Some(pieces) = stream {
|
||||
for i in 0..pieces {
|
||||
let len = 3 + (i * 7) % 14;
|
||||
send(Event::AssistantText {
|
||||
delta: format!("{i}{} ", "x".repeat(len)),
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(beats) = mixed {
|
||||
for beat in 1..=beats {
|
||||
write_beat(&sink, &dir, beat).await;
|
||||
|
||||
Reference in new issue
Block a user