app: smooth streaming markdown via incremental parser + typewriter reveal
- MarkdownText: streaming branch on the library's StreamingMarkdownState (rememberStreamingMarkdownPipeline) — incremental append, no full re-parse per snapshot; static path keeps retainState=true - client-side reveal: buffer snapshots, reveal at a steady rate (streamCharsPerSecond = 60/smoothness, 300..75 c/s) with rate-relative catch-up (jump when backlog > 2s of reveal time) - keep the streaming renderer alive after message.stop until the reveal catches up (useStreaming gate); artifact card waits for the same - cursor drawn via annotator, never part of the parse input - new 'Streaming speed' setting (0.2-0.8s) in Settings, persisted per device via SecureStore, live-updatable - docs/05-streaming: rate mapping, catch-up, wait-for-reveal semantics
This commit is contained in:
1 parent
c7a16d51e3
commit
83a67f6fb1
10 files changed
+500
-45
No files matched your search
@@ -9,6 +9,7 @@ import iris.data.SecureStore
|
||||
import iris.ui.theme.Backdrop
|
||||
import iris.ui.theme.BackgroundMode
|
||||
import iris.ui.theme.UserTheme
|
||||
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -134,6 +135,10 @@ class AndroidSecureStore(
|
||||
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
|
||||
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
|
||||
|
||||
override var streamSmoothness: Float
|
||||
get() = prefs.getFloat(KEY_STREAM_SMOOTHNESS, STREAM_SMOOTHNESS_DEFAULT)
|
||||
set(value) = prefs.edit().putFloat(KEY_STREAM_SMOOTHNESS, value).apply()
|
||||
|
||||
override var reasoningAutoCollapse: Boolean
|
||||
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
|
||||
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
|
||||
@@ -225,6 +230,7 @@ class AndroidSecureStore(
|
||||
const val KEY_THREADS_ENABLED = "threads_enabled"
|
||||
const val KEY_TOOL_DETAIL = "tool_detail"
|
||||
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
||||
const val KEY_STREAM_SMOOTHNESS = "stream_smoothness"
|
||||
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
|
||||
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
|
||||
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
|
||||
|
||||
@@ -54,6 +54,10 @@ interface SecureStore {
|
||||
/** UI setting: stream assistant replies live (token by token). */
|
||||
var streamingEnabled: Boolean
|
||||
|
||||
/** UI setting: streaming smoothness — seconds between visible text
|
||||
* updates while streaming (0.2–0.8; lower = faster/smoother). */
|
||||
var streamSmoothness: Float
|
||||
|
||||
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
|
||||
var reasoningAutoCollapse: Boolean
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ import iris.ui.theme.Backdrop
|
||||
import iris.ui.theme.BackgroundMode
|
||||
import iris.ui.theme.UserTheme
|
||||
import iris.util.IrisLog
|
||||
import iris.util.STREAM_SMOOTHNESS_MAX
|
||||
import iris.util.STREAM_SMOOTHNESS_MIN
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -267,6 +269,21 @@ class IrisController(
|
||||
chat.streamingEnabled = _streamingEnabled.value
|
||||
}
|
||||
|
||||
// Streaming smoothness (Settings → "Streaming speed"): seconds between
|
||||
// visible text updates while streaming (lower = faster/smoother).
|
||||
// Per-device display preference; applied on the fly by the reveal loop
|
||||
// in MarkdownText (docs/05 §5.1).
|
||||
private val _streamSmoothness =
|
||||
MutableStateFlow(store.streamSmoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX))
|
||||
val streamSmoothness: StateFlow<Float> = _streamSmoothness.asStateFlow()
|
||||
|
||||
fun setStreamSmoothness(value: Float) {
|
||||
val clamped = value.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
|
||||
if (clamped == _streamSmoothness.value) return
|
||||
_streamSmoothness.value = clamped
|
||||
store.streamSmoothness = clamped
|
||||
}
|
||||
|
||||
// ── Reasoning auto-collapse (Settings → "Reasoning") ───────────────────
|
||||
// Persisted. When on, long reasoning blocks start collapsed (short ones
|
||||
// stay expanded); when off, all reasoning blocks start expanded.
|
||||
|
||||
@@ -9,17 +9,21 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.LinkInteractionListener
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -37,15 +41,28 @@ import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.StreamingMarkdownState
|
||||
import com.mikepenz.markdown.model.markdownAnnotator
|
||||
import com.mikepenz.markdown.model.rememberMarkdownState
|
||||
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
|
||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||
import iris.util.appendChunk
|
||||
import iris.util.prepareForMarkdown
|
||||
import iris.util.preserveNewlinesAsHardBreaks
|
||||
import iris.util.preserveNewlinesAsHardBreaksStreaming
|
||||
import iris.util.revealStep
|
||||
import iris.util.streamCharsPerSecond
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
|
||||
/** Reveal tick for the streaming typewriter effect (docs/05 §5.1). */
|
||||
private const val STREAM_REVEAL_TICK_MS = 50L
|
||||
|
||||
/**
|
||||
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM
|
||||
@@ -53,6 +70,12 @@ import org.intellij.markdown.MarkdownElementTypes
|
||||
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
|
||||
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
|
||||
* rendered text blends in.
|
||||
*
|
||||
* While [isStreaming], the text is revealed at a steady rate (controlled by
|
||||
* [streamSmoothness], Settings → Streaming) and parsed INCREMENTALLY: settled
|
||||
* blocks are parsed once and never re-laid out, only the tail re-renders per
|
||||
* tick — so the bubble stays readable instead of reflowing (and flashing raw
|
||||
* markdown) on every gateway update.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownText(
|
||||
@@ -64,11 +87,30 @@ fun MarkdownText(
|
||||
// plain highlighted code — the artifact card (and its runnable preview)
|
||||
// only appears once the message is complete.
|
||||
isStreaming: Boolean = false,
|
||||
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
|
||||
) {
|
||||
val state = rememberMarkdownState(text)
|
||||
// Static path: transform once per text change (leading blanks stripped,
|
||||
// single newlines → hard breaks).
|
||||
val displayText = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaks() }
|
||||
// Whether this bubble has ever been streamed. Static history messages
|
||||
// skip the reveal pipeline entirely.
|
||||
val hasStreamed = remember { mutableStateOf(isStreaming) }
|
||||
if (isStreaming) hasStreamed.value = true
|
||||
val pipeline =
|
||||
rememberStreamingMarkdownPipeline(
|
||||
text = text,
|
||||
smoothness = streamSmoothness,
|
||||
active = hasStreamed.value,
|
||||
streaming = isStreaming,
|
||||
)
|
||||
// Keep the streaming renderer until the reveal catches up, even after
|
||||
// message.stop — a fast model that dumps the whole text at once still
|
||||
// plays out the typewriter instead of jumping to the full message.
|
||||
val useStreaming = hasStreamed.value && (isStreaming || !pipeline.done)
|
||||
// The app is always dark-themed, so force the dark highlight palette
|
||||
// (isSystemInDarkTheme() is unreliable on desktop).
|
||||
val highlights = remember {
|
||||
val highlights =
|
||||
remember {
|
||||
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
|
||||
}
|
||||
val base = TextStyle(color = color, fontSize = fontSize)
|
||||
@@ -80,7 +122,8 @@ fun MarkdownText(
|
||||
// Brief green flash shown on the chip right after a copy, so the tap is
|
||||
// visible feedback (the clipboard write itself is silent).
|
||||
val copiedCodeSpanStyle =
|
||||
base.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
|
||||
base
|
||||
.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
|
||||
.toSpanStyle()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -92,9 +135,11 @@ fun MarkdownText(
|
||||
// Re-render it without the padding, and wrap it in a link so tapping the
|
||||
// inline code copies it to the clipboard (Telegram-style). The
|
||||
// LinkAnnotation carries the click listener; the URL is never opened.
|
||||
val annotator = markdownAnnotator(
|
||||
val annotator =
|
||||
markdownAnnotator(
|
||||
annotate = { content, child ->
|
||||
if (child.type == MarkdownElementTypes.CODE_SPAN) {
|
||||
when {
|
||||
child.type == MarkdownElementTypes.CODE_SPAN -> {
|
||||
val children = child.children
|
||||
// Drop the surrounding backtick tokens (present as first/last child).
|
||||
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
|
||||
@@ -106,13 +151,15 @@ fun MarkdownText(
|
||||
url = "iris:copy-code",
|
||||
// Keep every interaction state identical to the chip so
|
||||
// hover/press never restyles the inline code.
|
||||
styles = TextLinkStyles(
|
||||
styles =
|
||||
TextLinkStyles(
|
||||
style = spanStyle,
|
||||
focusedStyle = spanStyle,
|
||||
hoveredStyle = spanStyle,
|
||||
pressedStyle = spanStyle,
|
||||
),
|
||||
linkInteractionListener = LinkInteractionListener {
|
||||
linkInteractionListener =
|
||||
LinkInteractionListener {
|
||||
clipboard.setText(AnnotatedString(code))
|
||||
copiedCode.value = code
|
||||
scope.launch {
|
||||
@@ -126,23 +173,36 @@ fun MarkdownText(
|
||||
}
|
||||
pop()
|
||||
true
|
||||
} else {
|
||||
}
|
||||
|
||||
// Streaming cursor: append ▉ to the last text leaf of the
|
||||
// document so the cursor sits at the end of the visible text.
|
||||
// The tail is re-annotated on every reveal tick, so the cursor
|
||||
// follows the text (and vanishes once the message is final).
|
||||
useStreaming &&
|
||||
child.type == MarkdownTokenTypes.TEXT &&
|
||||
content.length - child.endOffset <= 1 -> {
|
||||
append(child.getUnescapedTextInNode(content))
|
||||
append(" ▉")
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
Markdown(
|
||||
markdownState = state,
|
||||
modifier = modifier,
|
||||
annotator = annotator,
|
||||
colors = markdownColor(
|
||||
val colors =
|
||||
markdownColor(
|
||||
text = color,
|
||||
codeBackground = Color.Black.copy(alpha = 0.8f),
|
||||
inlineCodeBackground = inlineCodeBackground,
|
||||
dividerColor = IrisColors.divider,
|
||||
tableBackground = Color.White.copy(alpha = 0.03f),
|
||||
),
|
||||
typography = markdownTypography(
|
||||
)
|
||||
val typography =
|
||||
markdownTypography(
|
||||
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
|
||||
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
|
||||
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
|
||||
@@ -157,15 +217,17 @@ fun MarkdownText(
|
||||
ordered = base,
|
||||
bullet = base,
|
||||
list = base,
|
||||
textLink = TextLinkStyles(
|
||||
textLink =
|
||||
TextLinkStyles(
|
||||
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
|
||||
),
|
||||
table = base.copy(fontSize = fontSize * 0.95f),
|
||||
),
|
||||
components = markdownComponents(
|
||||
)
|
||||
val components =
|
||||
markdownComponents(
|
||||
codeFence = { model ->
|
||||
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
|
||||
if (!isStreaming && isHtmlArtifact(language, code)) {
|
||||
if (!useStreaming && isHtmlArtifact(language, code)) {
|
||||
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||
} else {
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
@@ -174,7 +236,7 @@ fun MarkdownText(
|
||||
},
|
||||
codeBlock = { model ->
|
||||
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
|
||||
if (!isStreaming && isHtmlArtifact(language, code)) {
|
||||
if (!useStreaming && isHtmlArtifact(language, code)) {
|
||||
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||
} else {
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
@@ -184,16 +246,128 @@ fun MarkdownText(
|
||||
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
|
||||
// Material 3 checkbox instead.
|
||||
checkbox = {
|
||||
com.mikepenz.markdown.m3.elements.MarkdownCheckBox(it.content, it.node, it.typography.text)
|
||||
com.mikepenz.markdown.m3.elements
|
||||
.MarkdownCheckBox(it.content, it.node, it.typography.text)
|
||||
},
|
||||
),
|
||||
)
|
||||
if (useStreaming) {
|
||||
if (pipeline.displayed.isEmpty()) {
|
||||
// No text revealed yet (message.start, first tick pending): show
|
||||
// just the cursor so the bubble is visible immediately.
|
||||
Text("▉", modifier = modifier, color = color, fontSize = fontSize)
|
||||
} else {
|
||||
Markdown(
|
||||
streamingMarkdownState = pipeline.state,
|
||||
modifier = modifier,
|
||||
annotator = annotator,
|
||||
colors = colors,
|
||||
typography = typography,
|
||||
components = components,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// retainState: keep the last formatted output visible while the new
|
||||
// text re-parses (async) — no raw-markdown flash between updates.
|
||||
val state = rememberMarkdownState(displayText, retainState = true)
|
||||
Markdown(
|
||||
markdownState = state,
|
||||
modifier = modifier,
|
||||
annotator = annotator,
|
||||
colors = colors,
|
||||
typography = typography,
|
||||
components = components,
|
||||
loading = { m ->
|
||||
// While (re)parsing — e.g. on each streaming update — show the raw
|
||||
// text so the bubble never goes blank between updates.
|
||||
Text(text, modifier = m, color = color, fontSize = fontSize)
|
||||
// First parse of a (long) message: show the text so the bubble
|
||||
// never goes blank.
|
||||
Text(displayText, modifier = m, color = color, fontSize = fontSize)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental streaming pipeline (docs/05 §5.1). [text] is the latest FULL
|
||||
* snapshot from the gateway; it is revealed at a steady rate controlled by
|
||||
* [smoothness] (typewriter effect), and the revealed prefix is fed into an
|
||||
* append-only [StreamingMarkdownState]. Settled blocks are parsed once and
|
||||
* keep their AST identity, so Compose never re-lays them out — only the
|
||||
* unstable tail re-renders per tick.
|
||||
*
|
||||
* [active] is false for history messages (never streamed): the reveal is
|
||||
* skipped and [StreamingPipeline.done] is true immediately. [streaming]
|
||||
* indicates the message is still receiving updates; once the reveal catches
|
||||
* up and the message is final, the reveal loop stops.
|
||||
*
|
||||
* Returns the parser state, the currently revealed text (read inside the
|
||||
* composition so reveal ticks recompose the caller), and whether the reveal
|
||||
* has caught up with the target.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberStreamingMarkdownPipeline(
|
||||
text: String,
|
||||
smoothness: Float,
|
||||
active: Boolean,
|
||||
streaming: Boolean,
|
||||
): StreamingPipeline {
|
||||
// Prefix-preserving transform (see preserveNewlinesAsHardBreaksStreaming):
|
||||
// a prefix of the revealed text always maps to a prefix of the target, so
|
||||
// the diff between consecutive reveals is a pure append.
|
||||
val target = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaksStreaming() }
|
||||
val displayed = remember { mutableStateOf("") }
|
||||
val latestTarget = rememberUpdatedState(target)
|
||||
val latestSmoothness = rememberUpdatedState(smoothness)
|
||||
val latestStreaming = rememberUpdatedState(streaming)
|
||||
// Bumped when the target is rewritten (not extended): the parser state is
|
||||
// recreated via key() below and re-seeded with the full text.
|
||||
val generation = remember { mutableIntStateOf(0) }
|
||||
val state = key(generation.value) { rememberStreamingMarkdownState() }
|
||||
LaunchedEffect(state, active) {
|
||||
if (!active) {
|
||||
// Never streamed (history message): reveal everything at once so
|
||||
// the caller falls back to the static renderer immediately.
|
||||
displayed.value = latestTarget.value
|
||||
return@LaunchedEffect
|
||||
}
|
||||
var last = ""
|
||||
while (true) {
|
||||
delay(STREAM_REVEAL_TICK_MS)
|
||||
val t = latestTarget.value
|
||||
val cur = displayed.value
|
||||
if (cur == t) {
|
||||
// Reveal complete: keep ticking only while the message is
|
||||
// still streaming (more text may arrive); otherwise stop so
|
||||
// finished bubbles don't burn battery.
|
||||
if (!latestStreaming.value) return@LaunchedEffect
|
||||
continue
|
||||
}
|
||||
val step =
|
||||
revealStep(
|
||||
remaining = t.length - cur.length,
|
||||
charsPerSecond = streamCharsPerSecond(latestSmoothness.value),
|
||||
tickSeconds = STREAM_REVEAL_TICK_MS / 1000.0,
|
||||
)
|
||||
val next = t.take(cur.length + step)
|
||||
displayed.value = next
|
||||
val chunk = appendChunk(last, next)
|
||||
if (chunk == null) {
|
||||
// Rewritten, not extended: recreate the parser state. The
|
||||
// effect restarts (keyed on [state]) and re-seeds it.
|
||||
generation.value++
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (chunk.isNotEmpty()) state.append(chunk)
|
||||
last = next
|
||||
}
|
||||
}
|
||||
return StreamingPipeline(state, displayed.value, displayed.value == target)
|
||||
}
|
||||
|
||||
/** Result of the streaming reveal pipeline. */
|
||||
private data class StreamingPipeline(
|
||||
val state: StreamingMarkdownState,
|
||||
val displayed: String,
|
||||
val done: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Renders a highlighted code block with a copy button in the top-right corner
|
||||
@@ -213,7 +387,8 @@ internal fun CodeBlockWithCopy(
|
||||
if (code.isNotBlank()) {
|
||||
IconButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(code)) },
|
||||
modifier = Modifier
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
// MarkdownHighlightedCode insets its background by 8dp top;
|
||||
// match that so the button sits inside the block, not above it.
|
||||
|
||||
@@ -143,12 +143,11 @@ import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.LocalUserTheme
|
||||
import iris.ui.theme.avatarColor
|
||||
import iris.ui.theme.contrastText
|
||||
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||
import iris.util.formatDayLabel
|
||||
import iris.util.formatTime
|
||||
import iris.util.fuzzyScore
|
||||
import iris.util.localDayKey
|
||||
import iris.util.prepareForMarkdown
|
||||
import iris.util.preserveNewlinesAsHardBreaks
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
@@ -171,6 +170,7 @@ fun ChatScreen(controller: IrisController) {
|
||||
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
||||
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
||||
val streamSmoothness by controller.streamSmoothness.collectAsState()
|
||||
|
||||
val channels by controller.channels.channels.collectAsState()
|
||||
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
|
||||
@@ -712,6 +712,7 @@ fun ChatScreen(controller: IrisController) {
|
||||
},
|
||||
runtimeFooterEnabled = runtimeFooterEnabled,
|
||||
runtimeFooterFields = runtimeFooterFields,
|
||||
streamSmoothness = streamSmoothness,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2521,6 +2522,7 @@ private fun MessageBubble(
|
||||
onSelect: (() -> Unit)? = null,
|
||||
runtimeFooterEnabled: Boolean = false,
|
||||
runtimeFooterFields: List<String> = emptyList(),
|
||||
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
|
||||
) {
|
||||
val isUser = msg.role == ROLE_USER
|
||||
val isCommentary = msg.isCommentary
|
||||
@@ -2594,9 +2596,7 @@ private fun MessageBubble(
|
||||
// in replies.
|
||||
if (msg.text.isNotBlank() || msg.streaming) {
|
||||
MarkdownText(
|
||||
text =
|
||||
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
||||
if (msg.streaming) " ▉" else "",
|
||||
text = msg.text,
|
||||
color = textColor,
|
||||
fontSize = 15.sp,
|
||||
isStreaming = msg.streaming,
|
||||
@@ -2641,20 +2641,18 @@ private fun MessageBubble(
|
||||
} else {
|
||||
if (msg.text.isNotBlank() || msg.streaming) {
|
||||
// M8: render the agent's reply as markdown (bold / italic /
|
||||
// underscore, tables, highlighted code blocks). Leading
|
||||
// newlines are stripped so the text hugs the top of the
|
||||
// bubble; single newlines become hard breaks (models write
|
||||
// status lines and wrapped text expecting a break per line,
|
||||
// same as user input); the ▉ cursor is kept while streaming.
|
||||
val displayText =
|
||||
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
||||
if (msg.streaming) " ▉" else ""
|
||||
// underscore, tables, highlighted code blocks). The
|
||||
// transform (leading-newline strip, hard breaks) and the ▉
|
||||
// streaming cursor live inside MarkdownText; while
|
||||
// streaming the text is revealed at a steady rate and
|
||||
// parsed incrementally (docs/05 §5.1).
|
||||
MarkdownText(
|
||||
text = displayText,
|
||||
text = msg.text,
|
||||
color = textColor,
|
||||
fontSize = if (isCommentary) 13.sp else 15.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isStreaming = msg.streaming,
|
||||
streamSmoothness = streamSmoothness,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ import iris.ui.theme.Backdrop
|
||||
import iris.ui.theme.BackgroundMode
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.LocalUserTheme
|
||||
import iris.util.STREAM_SMOOTHNESS_MAX
|
||||
import iris.util.STREAM_SMOOTHNESS_MIN
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -71,6 +73,7 @@ fun SettingsScreen(
|
||||
) {
|
||||
val threadsEnabled by controller.threadsEnabled.collectAsState()
|
||||
val streamingEnabled by controller.streamingEnabled.collectAsState()
|
||||
val streamSmoothness by controller.streamSmoothness.collectAsState()
|
||||
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
||||
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
||||
@@ -142,6 +145,27 @@ fun SettingsScreen(
|
||||
onCheckedChange = { controller.toggleStreaming() },
|
||||
)
|
||||
}
|
||||
if (streamingEnabled) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Streaming speed", fontSize = 12.sp, color = IrisColors.textDim)
|
||||
Text(
|
||||
"How fast new text appears while streaming — lower is faster",
|
||||
fontSize = 11.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Fast", fontSize = 12.sp, color = IrisColors.textDim)
|
||||
Slider(
|
||||
value = streamSmoothness,
|
||||
onValueChange = { controller.setStreamSmoothness(it) },
|
||||
valueRange = STREAM_SMOOTHNESS_MIN..STREAM_SMOOTHNESS_MAX,
|
||||
steps = 5, // 0.2 / 0.3 / 0.4 / 0.5 / 0.6 / 0.7 / 0.8
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text("Slow", fontSize = 12.sp, color = IrisColors.textDim)
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Row(
|
||||
|
||||
@@ -25,16 +25,107 @@ fun String.prepareForMarkdown(): String {
|
||||
fun String.preserveNewlinesAsHardBreaks(): String {
|
||||
val lines = split("\n")
|
||||
var inCodeBlock = false
|
||||
val out = lines.map { line ->
|
||||
val out =
|
||||
lines.map { line ->
|
||||
val trimmed = line.trimStart()
|
||||
when {
|
||||
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
||||
inCodeBlock = !inCodeBlock
|
||||
line
|
||||
}
|
||||
inCodeBlock || line.isBlank() -> line
|
||||
else -> line + " "
|
||||
|
||||
inCodeBlock || line.isBlank() -> {
|
||||
line
|
||||
}
|
||||
|
||||
else -> {
|
||||
line + " "
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.joinToString("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming variant of [preserveNewlinesAsHardBreaks]: identical, except the
|
||||
* trailing hard-break spaces are NOT added to the last (still-incomplete) line.
|
||||
*
|
||||
* This makes the transform *prefix-preserving*: for every prefix `p` of `s`,
|
||||
* `p.preserveNewlinesAsHardBreaksStreaming()` is a prefix of
|
||||
* `s.preserveNewlinesAsHardBreaksStreaming()`. That is what lets the streaming
|
||||
* renderer feed the diff between consecutive snapshots into an append-only
|
||||
* markdown parser (see `MarkdownText`). The final line's trailing spaces are
|
||||
* invisible at the end of the document, so a finished message renders exactly
|
||||
* like the static transform.
|
||||
*/
|
||||
fun String.preserveNewlinesAsHardBreaksStreaming(): String {
|
||||
val lines = split("\n")
|
||||
var inCodeBlock = false
|
||||
val out =
|
||||
lines.mapIndexed { index, line ->
|
||||
val trimmed = line.trimStart()
|
||||
when {
|
||||
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
||||
inCodeBlock = !inCodeBlock
|
||||
line
|
||||
}
|
||||
|
||||
inCodeBlock || line.isBlank() -> {
|
||||
line
|
||||
}
|
||||
|
||||
index == lines.lastIndex -> {
|
||||
line
|
||||
}
|
||||
|
||||
// still being typed
|
||||
else -> {
|
||||
line + " "
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.joinToString("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the suffix [next] adds on top of [last] (i.e. [next] minus its
|
||||
* [last] prefix), or `null` when [next] is NOT an extension of [last] — the
|
||||
* content was rewritten, not appended. The gateway sends full text snapshots
|
||||
* on every `message.update`; this converts them into append chunks for the
|
||||
* append-only streaming parser.
|
||||
*/
|
||||
fun appendChunk(
|
||||
last: String,
|
||||
next: String,
|
||||
): String? = if (next.startsWith(last)) next.substring(last.length) else null
|
||||
|
||||
/**
|
||||
* Number of characters to reveal on one streaming tick. The reveal rate comes
|
||||
* from the smoothness setting; when the backlog exceeds ~2 s of reveal time
|
||||
* (fast model, reconnect catch-up) everything is revealed at once so the
|
||||
* display never lags far behind the received text.
|
||||
*/
|
||||
fun revealStep(
|
||||
remaining: Int,
|
||||
charsPerSecond: Double,
|
||||
tickSeconds: Double,
|
||||
): Int {
|
||||
if (remaining <= 0) return 0
|
||||
if (remaining > charsPerSecond * 2.0) return remaining
|
||||
return maxOf(1, (charsPerSecond * tickSeconds).toInt())
|
||||
}
|
||||
|
||||
/**
|
||||
* Range of the streaming-smoothness setting (seconds between visible text
|
||||
* updates while streaming; lower = faster/smoother).
|
||||
*/
|
||||
const val STREAM_SMOOTHNESS_MIN = 0.2f
|
||||
const val STREAM_SMOOTHNESS_MAX = 0.8f
|
||||
const val STREAM_SMOOTHNESS_DEFAULT = 0.4f
|
||||
|
||||
/**
|
||||
* Reveal rate for a smoothness value. `60 / smoothness` keeps the display
|
||||
* well ahead of the gateway's arrival rate (~24 chars per 0.8 s) across the
|
||||
* whole range (0.2 → 300 chars/s, 0.8 → 75 chars/s).
|
||||
*/
|
||||
fun streamCharsPerSecond(smoothness: Float): Double = 60.0 / smoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
|
||||
@@ -2,9 +2,9 @@ package iris.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MarkdownTest {
|
||||
|
||||
@Test
|
||||
fun stripsLeadingNewlines() {
|
||||
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
|
||||
@@ -61,4 +61,90 @@ class MarkdownTest {
|
||||
val md = "**Title:** x\n**Model:** y"
|
||||
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingTransformSkipsLastLineOnly() {
|
||||
// Identical to the static transform except the (still-incomplete)
|
||||
// last line gets no trailing hard-break spaces.
|
||||
assertEquals("a \nb", "a\nb".preserveNewlinesAsHardBreaksStreaming())
|
||||
assertEquals("a \n\nb", "a\n\nb".preserveNewlinesAsHardBreaksStreaming())
|
||||
assertEquals("a", "a".preserveNewlinesAsHardBreaksStreaming())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingTransformLeavesFencedCodeBlocksUntouched() {
|
||||
val md = "```kotlin\nval a = 1\nval b = 2\n```"
|
||||
assertEquals(md, md.preserveNewlinesAsHardBreaksStreaming())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingTransformIsPrefixPreserving() {
|
||||
// The core invariant the incremental renderer relies on: for every
|
||||
// prefix p of s, transform(p) is a prefix of transform(s).
|
||||
val docs =
|
||||
listOf(
|
||||
"a\nb",
|
||||
"a\n\nb\nc",
|
||||
"**bold** and `code`",
|
||||
"```kotlin\nval a = 1\n```\nthen text",
|
||||
"~~~\nx\n~~~\ny",
|
||||
"| a | b |\n| - | - |\n| 1 | 2 |",
|
||||
"- item\n- item2",
|
||||
"line with trailing spaces \nnext",
|
||||
"",
|
||||
"\n\n \n",
|
||||
)
|
||||
for (s in docs) {
|
||||
val whole = s.preserveNewlinesAsHardBreaksStreaming()
|
||||
for (i in 0..s.length) {
|
||||
val p = s.take(i)
|
||||
val tp = p.preserveNewlinesAsHardBreaksStreaming()
|
||||
assertTrue(
|
||||
whole.startsWith(tp),
|
||||
"prefix <$tp> not a prefix of <$whole> (doc=<$s>)",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appendChunkReturnsSuffixOnExtension() {
|
||||
assertEquals(" world", appendChunk("Hello", "Hello world"))
|
||||
assertEquals("", appendChunk("abc", "abc"))
|
||||
assertEquals("abc", appendChunk("", "abc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appendChunkReturnsNullOnRewrite() {
|
||||
assertEquals(null, appendChunk("Hello", "Hi"))
|
||||
assertEquals(null, appendChunk("Hello world", "Hello"))
|
||||
assertEquals(null, appendChunk("a ", "ab")) // hard-break spaces shift
|
||||
}
|
||||
|
||||
@Test
|
||||
fun revealStepRevealsAtLeastOneChar() {
|
||||
assertEquals(1, revealStep(2, charsPerSecond = 1.0, tickSeconds = 0.05))
|
||||
assertEquals(3, revealStep(100, charsPerSecond = 60.0, tickSeconds = 0.05))
|
||||
assertEquals(0, revealStep(0, charsPerSecond = 60.0, tickSeconds = 0.05))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun revealStepCatchesUpWhenBacklogIsLarge() {
|
||||
// Backlog beyond ~2 s of reveal time (120 * 2 = 240) jumps at once.
|
||||
assertEquals(500, revealStep(500, charsPerSecond = 120.0, tickSeconds = 0.05))
|
||||
assertEquals(241, revealStep(241, charsPerSecond = 120.0, tickSeconds = 0.05))
|
||||
// At exactly 2 s of backlog it still steps normally (6 = 120 * 0.05).
|
||||
assertEquals(6, revealStep(240, charsPerSecond = 120.0, tickSeconds = 0.05))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamCharsPerSecondMapsSmoothnessRange() {
|
||||
// 0.2 → 300 chars/s (fast), 0.8 → 75 chars/s (slow); out-of-range
|
||||
// values are clamped to the ends.
|
||||
assertEquals(300.0, streamCharsPerSecond(0.2f), 0.001)
|
||||
assertEquals(150.0, streamCharsPerSecond(0.4f), 0.001)
|
||||
assertEquals(75.0, streamCharsPerSecond(0.8f), 0.001)
|
||||
assertEquals(300.0, streamCharsPerSecond(0.05f), 0.001)
|
||||
assertEquals(75.0, streamCharsPerSecond(2.0f), 0.001)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import iris.protocol.IrisJson
|
||||
import iris.ui.theme.Backdrop
|
||||
import iris.ui.theme.BackgroundMode
|
||||
import iris.ui.theme.UserTheme
|
||||
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.security.SecureRandom
|
||||
@@ -64,6 +65,7 @@ class DesktopSecureStore : SecureStore {
|
||||
val threadsEnabled: Boolean = false,
|
||||
val toolDetail: String = "truncated",
|
||||
val streamingEnabled: Boolean = true,
|
||||
val streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
|
||||
val reasoningAutoCollapse: Boolean = true,
|
||||
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
|
||||
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
|
||||
@@ -222,6 +224,13 @@ class DesktopSecureStore : SecureStore {
|
||||
save(d.copy(streamingEnabled = value))
|
||||
}
|
||||
|
||||
override var streamSmoothness: Float
|
||||
get() = load().streamSmoothness
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(streamSmoothness = value))
|
||||
}
|
||||
|
||||
override var reasoningAutoCollapse: Boolean
|
||||
get() = load().reasoningAutoCollapse
|
||||
set(value) {
|
||||
|
||||
@@ -12,11 +12,13 @@ produced by the gateway and rendered by the app.
|
||||
**Gateway side.** The agent's `stream_delta_callback` feeds a
|
||||
`GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer
|
||||
accumulates text and, at intervals / thresholds, calls:
|
||||
|
||||
- `adapter.send(chat_id, text)` — first time a bubble is created.
|
||||
- `adapter.edit_message(chat_id, message_id, text)` — subsequent updates
|
||||
(each carries the **full** accumulated text).
|
||||
|
||||
**Adapter → frames.**
|
||||
|
||||
- First `send()` of a turn segment → `message.start {message_id, role}`.
|
||||
- Each `edit_message()` → `message.update {message_id, text}` (full text).
|
||||
- Segment/turn finalization → `message.stop {message_id, final_text, reasoning?,
|
||||
@@ -27,7 +29,44 @@ replace the bubble text (cheap: it's a full snapshot). On `message.stop`,
|
||||
finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
|
||||
while the user is at the bottom.
|
||||
|
||||
**Smooth streaming (app-side rendering).** Re-parsing + re-laying-out the
|
||||
whole bubble on every update made streamed text unreadable (raw-markdown
|
||||
flashes, constant reflow). `MarkdownText` therefore renders streaming bubbles
|
||||
incrementally:
|
||||
|
||||
- **Reveal (typewriter):** the latest full snapshot is revealed at a steady
|
||||
rate instead of jumping per gateway update. The rate is user-adjustable:
|
||||
Settings → "Streaming speed" (`streamSmoothness`, 0.2–0.8 s between visible
|
||||
updates, lower = faster; `streamCharsPerSecond` maps it to chars/s:
|
||||
`60 / smoothness`, so 0.2 → 300 chars/s, 0.8 → 75 chars/s). Applied on the
|
||||
fly; a backlog exceeding ~2 s of reveal time is revealed at once (fast
|
||||
model / reconnect catch-up).
|
||||
- **Wait for the reveal:** the streaming renderer stays active until the
|
||||
reveal catches up, even after `message.stop` — a fast model that dumps the
|
||||
whole text in one or two frames still plays out the typewriter instead of
|
||||
jumping to the full message. Only then does the bubble switch to the static
|
||||
renderer (which also enables the HTML artifact card).
|
||||
- **Incremental parse:** the revealed prefix is fed as append chunks
|
||||
(`appendChunk`) into the library's `StreamingMarkdownState`
|
||||
(`rememberStreamingMarkdownState`, mikepenz 0.44.0) — an append-only parser
|
||||
that re-parses only the unstable tail. Settled blocks keep AST identity, so
|
||||
Compose never re-lays them out; only the tail re-renders per tick. A
|
||||
non-extension snapshot (rewrite) recreates the parser state and re-seeds it.
|
||||
- **Prefix-preserving transform:** `preserveNewlinesAsHardBreaksStreaming()`
|
||||
is like `preserveNewlinesAsHardBreaks()` but skips the still-incomplete last
|
||||
line, so the transform of a prefix is always a prefix of the transform of
|
||||
the whole (required for pure-append diffs). The static path keeps the
|
||||
original transform plus `retainState = true` (last formatted output stays
|
||||
visible during re-parses — no raw flash).
|
||||
- **Cursor:** the ▉ is appended to the last text leaf by the annotator (not to
|
||||
the parse input, which would break the append diff).
|
||||
|
||||
The gateway cadence (`edit_interval` / `buffer_threshold`, default 0.8 s /
|
||||
24 chars) is unchanged — smoothing happens entirely client-side, so it works
|
||||
with any gateway and per device.
|
||||
|
||||
**Streaming on/off.** Two levels:
|
||||
|
||||
- **Gateway side:** hermes `display.platforms.iris.streaming` (default
|
||||
follows global). When off, the app just gets one final `message` frame.
|
||||
- **App side (per device):** Settings → "Streaming" toggle (default on). When
|
||||
@@ -45,11 +84,13 @@ reference screenshot's "Reasoning:" panel with a copy button).
|
||||
**Gateway side.** hermes prepends reasoning to the final response when
|
||||
`show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and
|
||||
chosen by `reasoning_style` (`gateway/display_config.py:37`):
|
||||
|
||||
- `code` (default): `💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>`
|
||||
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
|
||||
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
|
||||
|
||||
**Plugin config.** Set for the `iris` platform:
|
||||
|
||||
```yaml
|
||||
display:
|
||||
platforms:
|
||||
@@ -59,12 +100,14 @@ display:
|
||||
```
|
||||
|
||||
**Adapter split.** In `send()`, detect the `code`-style prefix and split:
|
||||
|
||||
```
|
||||
prefix = "💭 **Reasoning:**\n```\n"
|
||||
# find the closing "\n```\n\n" after the prefix
|
||||
reasoning = text[len(prefix):close_idx]
|
||||
body = text[close_idx + len("\n```\n\n"):]
|
||||
```
|
||||
|
||||
Emit `message {reasoning: <reasoning>, text: <body>, …}`. If no prefix is found
|
||||
(reasoning off / no reasoning), emit `message {text: …}` with no `reasoning`.
|
||||
|
||||
@@ -89,6 +132,7 @@ gateway progress queue → `send_progress_messages` (`gateway/run.py:4603`) →
|
||||
|
||||
**Adapter → frames.** The adapter classifies tool activity (via turn-state +
|
||||
line format) and emits **structured** frames — not pre-formatted strings:
|
||||
|
||||
- `tool.start {index, name, preview, args}` — a tool call began.
|
||||
- `tool.progress {index, name, note}` — in-progress update (optional).
|
||||
- `tool.end {index, name, ok, duration, output_preview}` — completed.
|
||||
@@ -99,6 +143,7 @@ short tail; full tool output is **not** streamed (it lives in agent history and
|
||||
is reachable via search).
|
||||
|
||||
**App side — the verbosity setting** (Settings → "Tool detail"):
|
||||
|
||||
- **Everything** — show tool name, full args (collapsible), and output preview.
|
||||
- **Truncated** (default) — show `emoji name: "short preview"` one-liner,
|
||||
collapsible to expand.
|
||||
|
||||
Reference in new issue
Block a user