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
+124
-38
No files matched your search
@@ -2,6 +2,7 @@ package com.example.aiapp
|
|||||||
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.Markdown
|
||||||
import com.mikepenz.markdown.m3.markdownColor
|
import com.mikepenz.markdown.m3.markdownColor
|
||||||
import com.mikepenz.markdown.m3.markdownTypography
|
import com.mikepenz.markdown.m3.markdownTypography
|
||||||
|
import com.mikepenz.markdown.model.parseMarkdown
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An assistant's reply, rendered as the markdown it is written in.
|
* An assistant's reply, rendered as the markdown it is written in.
|
||||||
@@ -24,8 +26,22 @@ import com.mikepenz.markdown.m3.markdownTypography
|
|||||||
@Composable
|
@Composable
|
||||||
fun MarkdownText(text: String, modifier: Modifier = Modifier) {
|
fun MarkdownText(text: String, modifier: Modifier = Modifier) {
|
||||||
val body = MaterialTheme.typography.bodyLarge
|
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(
|
Markdown(
|
||||||
content = text,
|
parsed,
|
||||||
colors =
|
colors =
|
||||||
markdownColor(
|
markdownColor(
|
||||||
text = MaterialTheme.colorScheme.onSurface,
|
text = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|||||||
@@ -489,12 +489,41 @@ fun SessionScreen(
|
|||||||
var loadingHistory by remember { mutableStateOf(false) }
|
var loadingHistory by remember { mutableStateOf(false) }
|
||||||
var ready by remember { mutableStateOf(false) }
|
var ready by remember { mutableStateOf(false) }
|
||||||
val listState = rememberLazyListState()
|
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
|
// What is actually drawn: the transcript with runs of adjacent tool
|
||||||
// calls folded into one row each.
|
// calls folded into one row each.
|
||||||
val rows = remember(items) { groupToolRuns(items) }
|
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
|
// The oldest event this view holds, which is what paging backwards
|
||||||
// starts from. Maintained here rather than by each loader: the
|
// starts from. Maintained here rather than by each loader: the
|
||||||
// first page and a stream reset both begin an empty view, and one
|
// first page and a stream reset both begin an empty view, and one
|
||||||
@@ -503,6 +532,44 @@ fun SessionScreen(
|
|||||||
oldestSeq = entry.seq
|
oldestSeq = entry.seq
|
||||||
moreHistory = entry.seq > 1L
|
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) {
|
when (val event = entry.event) {
|
||||||
// Taken, not accumulated: the server's running total is on the event, and adding
|
// 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
|
// up the deltas this screen happened to receive counted one page of a conversation
|
||||||
@@ -532,34 +599,9 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
status = event.state
|
status = event.state
|
||||||
}
|
}
|
||||||
// The message coming back is the session saying it has
|
// In order, always: one late event recorded ahead of the backlog would fold a
|
||||||
// read it, so the bubble held below the indicator becomes
|
// streamed delta into whatever row happened to be last by then.
|
||||||
// the row `foldEvent` is about to add. Matched by text --
|
if (atNewest && held.isEmpty()) record(entry) else held = held + entry
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -632,6 +674,7 @@ fun SessionScreen(
|
|||||||
// up pages the rest back in as it always does.
|
// up pages the rest back in as it always does.
|
||||||
items = listOf()
|
items = listOf()
|
||||||
loaded = listOf()
|
loaded = listOf()
|
||||||
|
held = listOf()
|
||||||
oldestSeq = 0L
|
oldestSeq = 0L
|
||||||
moreHistory = true
|
moreHistory = true
|
||||||
},
|
},
|
||||||
@@ -659,13 +702,17 @@ fun SessionScreen(
|
|||||||
// stays started.
|
// stays started.
|
||||||
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
||||||
|
|
||||||
// Whether the newest message is on screen right now. The list is laid out from the bottom
|
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather
|
||||||
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
|
// than paced out: they are at the bottom, which is the one place the list is allowed to
|
||||||
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
|
// follow new content, and drip-feeding it would only make that following last longer.
|
||||||
val atNewest by remember {
|
LaunchedEffect(listState) {
|
||||||
derivedStateOf {
|
snapshotFlow { atNewest && held.isNotEmpty() }
|
||||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
.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
|
// Whether they *chose* to be there, which is a different question and the one that decides
|
||||||
// whether an arriving message brings the view with it.
|
// whether an arriving message brings the view with it.
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
//! This is exactly the event vocabulary the real drivers produce, so a UI
|
//! This is exactly the event vocabulary the real drivers produce, so a UI
|
||||||
//! that renders echo sessions correctly renders the real thing.
|
//! 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
|
//! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of
|
||||||
//! different lengths, single tool calls, runs of adjacent ones, images
|
//! different lengths, single tool calls, runs of adjacent ones, images
|
||||||
//! and a peer message. Rows of every shape and height the app draws, in
|
//! 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
|
// 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
|
// pinned running for an hour by a typo is a worse outcome than a
|
||||||
// short wait.
|
// short wait.
|
||||||
|
let stream = text
|
||||||
|
.strip_prefix("/stream")
|
||||||
|
.map(|rest| rest.trim().parse::<usize>().unwrap_or(400).clamp(1, 4000));
|
||||||
let mixed = text
|
let mixed = text
|
||||||
.strip_prefix("/mixed")
|
.strip_prefix("/mixed")
|
||||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
|
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
|
||||||
@@ -423,6 +429,23 @@ impl EchoDriver {
|
|||||||
return;
|
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 {
|
if let Some(beats) = mixed {
|
||||||
for beat in 1..=beats {
|
for beat in 1..=beats {
|
||||||
write_beat(&sink, &dir, beat).await;
|
write_beat(&sink, &dir, beat).await;
|
||||||
|
|||||||
Reference in new issue
Block a user