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
+502
-47
No files matched your search
@@ -9,6 +9,7 @@ import iris.data.SecureStore
|
|||||||
import iris.ui.theme.Backdrop
|
import iris.ui.theme.Backdrop
|
||||||
import iris.ui.theme.BackgroundMode
|
import iris.ui.theme.BackgroundMode
|
||||||
import iris.ui.theme.UserTheme
|
import iris.ui.theme.UserTheme
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +135,10 @@ class AndroidSecureStore(
|
|||||||
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
|
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
|
||||||
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
|
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
|
override var reasoningAutoCollapse: Boolean
|
||||||
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
|
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
|
||||||
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
|
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_THREADS_ENABLED = "threads_enabled"
|
||||||
const val KEY_TOOL_DETAIL = "tool_detail"
|
const val KEY_TOOL_DETAIL = "tool_detail"
|
||||||
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
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_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
|
||||||
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
|
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
|
||||||
const val KEY_AGENT_BUBBLE_COLOR = "agent_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). */
|
/** UI setting: stream assistant replies live (token by token). */
|
||||||
var streamingEnabled: Boolean
|
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. */
|
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
|
||||||
var reasoningAutoCollapse: Boolean
|
var reasoningAutoCollapse: Boolean
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ import iris.ui.theme.Backdrop
|
|||||||
import iris.ui.theme.BackgroundMode
|
import iris.ui.theme.BackgroundMode
|
||||||
import iris.ui.theme.UserTheme
|
import iris.ui.theme.UserTheme
|
||||||
import iris.util.IrisLog
|
import iris.util.IrisLog
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_MAX
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_MIN
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -267,6 +269,21 @@ class IrisController(
|
|||||||
chat.streamingEnabled = _streamingEnabled.value
|
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") ───────────────────
|
// ── Reasoning auto-collapse (Settings → "Reasoning") ───────────────────
|
||||||
// Persisted. When on, long reasoning blocks start collapsed (short ones
|
// Persisted. When on, long reasoning blocks start collapsed (short ones
|
||||||
// stay expanded); when off, all reasoning blocks start expanded.
|
// 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.IconButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.LinkInteractionListener
|
import androidx.compose.ui.text.LinkInteractionListener
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.TextStyle
|
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.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.StreamingMarkdownState
|
||||||
import com.mikepenz.markdown.model.markdownAnnotator
|
import com.mikepenz.markdown.model.markdownAnnotator
|
||||||
import com.mikepenz.markdown.model.rememberMarkdownState
|
import com.mikepenz.markdown.model.rememberMarkdownState
|
||||||
|
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
|
||||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||||
import dev.snipme.highlights.Highlights
|
import dev.snipme.highlights.Highlights
|
||||||
import dev.snipme.highlights.model.SyntaxThemes
|
import dev.snipme.highlights.model.SyntaxThemes
|
||||||
import iris.ui.theme.IrisColors
|
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.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.intellij.markdown.MarkdownElementTypes
|
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
|
* 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
|
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
|
||||||
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
|
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
|
||||||
* rendered text blends in.
|
* 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
|
@Composable
|
||||||
fun MarkdownText(
|
fun MarkdownText(
|
||||||
@@ -64,11 +87,30 @@ fun MarkdownText(
|
|||||||
// plain highlighted code — the artifact card (and its runnable preview)
|
// plain highlighted code — the artifact card (and its runnable preview)
|
||||||
// only appears once the message is complete.
|
// only appears once the message is complete.
|
||||||
isStreaming: Boolean = false,
|
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
|
// The app is always dark-themed, so force the dark highlight palette
|
||||||
// (isSystemInDarkTheme() is unreliable on desktop).
|
// (isSystemInDarkTheme() is unreliable on desktop).
|
||||||
val highlights = remember {
|
val highlights =
|
||||||
|
remember {
|
||||||
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
|
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
|
||||||
}
|
}
|
||||||
val base = TextStyle(color = color, fontSize = fontSize)
|
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
|
// Brief green flash shown on the chip right after a copy, so the tap is
|
||||||
// visible feedback (the clipboard write itself is silent).
|
// visible feedback (the clipboard write itself is silent).
|
||||||
val copiedCodeSpanStyle =
|
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()
|
.toSpanStyle()
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -92,9 +135,11 @@ fun MarkdownText(
|
|||||||
// Re-render it without the padding, and wrap it in a link so tapping the
|
// 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
|
// inline code copies it to the clipboard (Telegram-style). The
|
||||||
// LinkAnnotation carries the click listener; the URL is never opened.
|
// LinkAnnotation carries the click listener; the URL is never opened.
|
||||||
val annotator = markdownAnnotator(
|
val annotator =
|
||||||
|
markdownAnnotator(
|
||||||
annotate = { content, child ->
|
annotate = { content, child ->
|
||||||
if (child.type == MarkdownElementTypes.CODE_SPAN) {
|
when {
|
||||||
|
child.type == MarkdownElementTypes.CODE_SPAN -> {
|
||||||
val children = child.children
|
val children = child.children
|
||||||
// Drop the surrounding backtick tokens (present as first/last child).
|
// Drop the surrounding backtick tokens (present as first/last child).
|
||||||
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
|
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
|
||||||
@@ -106,13 +151,15 @@ fun MarkdownText(
|
|||||||
url = "iris:copy-code",
|
url = "iris:copy-code",
|
||||||
// Keep every interaction state identical to the chip so
|
// Keep every interaction state identical to the chip so
|
||||||
// hover/press never restyles the inline code.
|
// hover/press never restyles the inline code.
|
||||||
styles = TextLinkStyles(
|
styles =
|
||||||
|
TextLinkStyles(
|
||||||
style = spanStyle,
|
style = spanStyle,
|
||||||
focusedStyle = spanStyle,
|
focusedStyle = spanStyle,
|
||||||
hoveredStyle = spanStyle,
|
hoveredStyle = spanStyle,
|
||||||
pressedStyle = spanStyle,
|
pressedStyle = spanStyle,
|
||||||
),
|
),
|
||||||
linkInteractionListener = LinkInteractionListener {
|
linkInteractionListener =
|
||||||
|
LinkInteractionListener {
|
||||||
clipboard.setText(AnnotatedString(code))
|
clipboard.setText(AnnotatedString(code))
|
||||||
copiedCode.value = code
|
copiedCode.value = code
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -126,23 +173,36 @@ fun MarkdownText(
|
|||||||
}
|
}
|
||||||
pop()
|
pop()
|
||||||
true
|
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
|
false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Markdown(
|
val colors =
|
||||||
markdownState = state,
|
markdownColor(
|
||||||
modifier = modifier,
|
|
||||||
annotator = annotator,
|
|
||||||
colors = markdownColor(
|
|
||||||
text = color,
|
text = color,
|
||||||
codeBackground = Color.Black.copy(alpha = 0.8f),
|
codeBackground = Color.Black.copy(alpha = 0.8f),
|
||||||
inlineCodeBackground = inlineCodeBackground,
|
inlineCodeBackground = inlineCodeBackground,
|
||||||
dividerColor = IrisColors.divider,
|
dividerColor = IrisColors.divider,
|
||||||
tableBackground = Color.White.copy(alpha = 0.03f),
|
tableBackground = Color.White.copy(alpha = 0.03f),
|
||||||
),
|
)
|
||||||
typography = markdownTypography(
|
val typography =
|
||||||
|
markdownTypography(
|
||||||
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
|
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
|
||||||
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
|
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
|
||||||
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
|
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
|
||||||
@@ -157,15 +217,17 @@ fun MarkdownText(
|
|||||||
ordered = base,
|
ordered = base,
|
||||||
bullet = base,
|
bullet = base,
|
||||||
list = base,
|
list = base,
|
||||||
textLink = TextLinkStyles(
|
textLink =
|
||||||
|
TextLinkStyles(
|
||||||
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
|
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
|
||||||
),
|
),
|
||||||
table = base.copy(fontSize = fontSize * 0.95f),
|
table = base.copy(fontSize = fontSize * 0.95f),
|
||||||
),
|
)
|
||||||
components = markdownComponents(
|
val components =
|
||||||
|
markdownComponents(
|
||||||
codeFence = { model ->
|
codeFence = { model ->
|
||||||
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
|
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)
|
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||||
} else {
|
} else {
|
||||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||||
@@ -174,7 +236,7 @@ fun MarkdownText(
|
|||||||
},
|
},
|
||||||
codeBlock = { model ->
|
codeBlock = { model ->
|
||||||
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
|
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)
|
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||||
} else {
|
} else {
|
||||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||||
@@ -184,17 +246,129 @@ fun MarkdownText(
|
|||||||
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
|
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
|
||||||
// Material 3 checkbox instead.
|
// Material 3 checkbox instead.
|
||||||
checkbox = {
|
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)
|
||||||
),
|
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
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 ->
|
||||||
|
// 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
|
* Renders a highlighted code block with a copy button in the top-right corner
|
||||||
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
|
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
|
||||||
@@ -213,7 +387,8 @@ internal fun CodeBlockWithCopy(
|
|||||||
if (code.isNotBlank()) {
|
if (code.isNotBlank()) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { clipboard.setText(AnnotatedString(code)) },
|
onClick = { clipboard.setText(AnnotatedString(code)) },
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
// MarkdownHighlightedCode insets its background by 8dp top;
|
// MarkdownHighlightedCode insets its background by 8dp top;
|
||||||
// match that so the button sits inside the block, not above it.
|
// 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.LocalUserTheme
|
||||||
import iris.ui.theme.avatarColor
|
import iris.ui.theme.avatarColor
|
||||||
import iris.ui.theme.contrastText
|
import iris.ui.theme.contrastText
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||||
import iris.util.formatDayLabel
|
import iris.util.formatDayLabel
|
||||||
import iris.util.formatTime
|
import iris.util.formatTime
|
||||||
import iris.util.fuzzyScore
|
import iris.util.fuzzyScore
|
||||||
import iris.util.localDayKey
|
import iris.util.localDayKey
|
||||||
import iris.util.prepareForMarkdown
|
|
||||||
import iris.util.preserveNewlinesAsHardBreaks
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -171,6 +170,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||||
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
||||||
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
||||||
|
val streamSmoothness by controller.streamSmoothness.collectAsState()
|
||||||
|
|
||||||
val channels by controller.channels.channels.collectAsState()
|
val channels by controller.channels.channels.collectAsState()
|
||||||
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
|
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
|
||||||
@@ -712,6 +712,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
},
|
},
|
||||||
runtimeFooterEnabled = runtimeFooterEnabled,
|
runtimeFooterEnabled = runtimeFooterEnabled,
|
||||||
runtimeFooterFields = runtimeFooterFields,
|
runtimeFooterFields = runtimeFooterFields,
|
||||||
|
streamSmoothness = streamSmoothness,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2521,6 +2522,7 @@ private fun MessageBubble(
|
|||||||
onSelect: (() -> Unit)? = null,
|
onSelect: (() -> Unit)? = null,
|
||||||
runtimeFooterEnabled: Boolean = false,
|
runtimeFooterEnabled: Boolean = false,
|
||||||
runtimeFooterFields: List<String> = emptyList(),
|
runtimeFooterFields: List<String> = emptyList(),
|
||||||
|
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
|
||||||
) {
|
) {
|
||||||
val isUser = msg.role == ROLE_USER
|
val isUser = msg.role == ROLE_USER
|
||||||
val isCommentary = msg.isCommentary
|
val isCommentary = msg.isCommentary
|
||||||
@@ -2594,9 +2596,7 @@ private fun MessageBubble(
|
|||||||
// in replies.
|
// in replies.
|
||||||
if (msg.text.isNotBlank() || msg.streaming) {
|
if (msg.text.isNotBlank() || msg.streaming) {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
text =
|
text = msg.text,
|
||||||
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
|
||||||
if (msg.streaming) " ▉" else "",
|
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
isStreaming = msg.streaming,
|
isStreaming = msg.streaming,
|
||||||
@@ -2641,20 +2641,18 @@ private fun MessageBubble(
|
|||||||
} else {
|
} else {
|
||||||
if (msg.text.isNotBlank() || msg.streaming) {
|
if (msg.text.isNotBlank() || msg.streaming) {
|
||||||
// M8: render the agent's reply as markdown (bold / italic /
|
// M8: render the agent's reply as markdown (bold / italic /
|
||||||
// underscore, tables, highlighted code blocks). Leading
|
// underscore, tables, highlighted code blocks). The
|
||||||
// newlines are stripped so the text hugs the top of the
|
// transform (leading-newline strip, hard breaks) and the ▉
|
||||||
// bubble; single newlines become hard breaks (models write
|
// streaming cursor live inside MarkdownText; while
|
||||||
// status lines and wrapped text expecting a break per line,
|
// streaming the text is revealed at a steady rate and
|
||||||
// same as user input); the ▉ cursor is kept while streaming.
|
// parsed incrementally (docs/05 §5.1).
|
||||||
val displayText =
|
|
||||||
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
|
||||||
if (msg.streaming) " ▉" else ""
|
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
text = displayText,
|
text = msg.text,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = if (isCommentary) 13.sp else 15.sp,
|
fontSize = if (isCommentary) 13.sp else 15.sp,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
isStreaming = msg.streaming,
|
isStreaming = msg.streaming,
|
||||||
|
streamSmoothness = streamSmoothness,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import iris.ui.theme.Backdrop
|
|||||||
import iris.ui.theme.BackgroundMode
|
import iris.ui.theme.BackgroundMode
|
||||||
import iris.ui.theme.IrisColors
|
import iris.ui.theme.IrisColors
|
||||||
import iris.ui.theme.LocalUserTheme
|
import iris.ui.theme.LocalUserTheme
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_MAX
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_MIN
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,6 +73,7 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
val threadsEnabled by controller.threadsEnabled.collectAsState()
|
val threadsEnabled by controller.threadsEnabled.collectAsState()
|
||||||
val streamingEnabled by controller.streamingEnabled.collectAsState()
|
val streamingEnabled by controller.streamingEnabled.collectAsState()
|
||||||
|
val streamSmoothness by controller.streamSmoothness.collectAsState()
|
||||||
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||||
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
||||||
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
||||||
@@ -142,6 +145,27 @@ fun SettingsScreen(
|
|||||||
onCheckedChange = { controller.toggleStreaming() },
|
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 {
|
SettingsCard {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -25,16 +25,107 @@ fun String.prepareForMarkdown(): String {
|
|||||||
fun String.preserveNewlinesAsHardBreaks(): String {
|
fun String.preserveNewlinesAsHardBreaks(): String {
|
||||||
val lines = split("\n")
|
val lines = split("\n")
|
||||||
var inCodeBlock = false
|
var inCodeBlock = false
|
||||||
val out = lines.map { line ->
|
val out =
|
||||||
|
lines.map { line ->
|
||||||
val trimmed = line.trimStart()
|
val trimmed = line.trimStart()
|
||||||
when {
|
when {
|
||||||
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
||||||
inCodeBlock = !inCodeBlock
|
inCodeBlock = !inCodeBlock
|
||||||
line
|
line
|
||||||
}
|
}
|
||||||
inCodeBlock || line.isBlank() -> line
|
|
||||||
else -> line + " "
|
inCodeBlock || line.isBlank() -> {
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
line + " "
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out.joinToString("\n")
|
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.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class MarkdownTest {
|
class MarkdownTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun stripsLeadingNewlines() {
|
fun stripsLeadingNewlines() {
|
||||||
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
|
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
|
||||||
@@ -61,4 +61,90 @@ class MarkdownTest {
|
|||||||
val md = "**Title:** x\n**Model:** y"
|
val md = "**Title:** x\n**Model:** y"
|
||||||
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks())
|
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.Backdrop
|
||||||
import iris.ui.theme.BackgroundMode
|
import iris.ui.theme.BackgroundMode
|
||||||
import iris.ui.theme.UserTheme
|
import iris.ui.theme.UserTheme
|
||||||
|
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
@@ -64,6 +65,7 @@ class DesktopSecureStore : SecureStore {
|
|||||||
val threadsEnabled: Boolean = false,
|
val threadsEnabled: Boolean = false,
|
||||||
val toolDetail: String = "truncated",
|
val toolDetail: String = "truncated",
|
||||||
val streamingEnabled: Boolean = true,
|
val streamingEnabled: Boolean = true,
|
||||||
|
val streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
|
||||||
val reasoningAutoCollapse: Boolean = true,
|
val reasoningAutoCollapse: Boolean = true,
|
||||||
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
|
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
|
||||||
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
|
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
|
||||||
@@ -222,6 +224,13 @@ class DesktopSecureStore : SecureStore {
|
|||||||
save(d.copy(streamingEnabled = value))
|
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
|
override var reasoningAutoCollapse: Boolean
|
||||||
get() = load().reasoningAutoCollapse
|
get() = load().reasoningAutoCollapse
|
||||||
set(value) {
|
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
|
**Gateway side.** The agent's `stream_delta_callback` feeds a
|
||||||
`GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer
|
`GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer
|
||||||
accumulates text and, at intervals / thresholds, calls:
|
accumulates text and, at intervals / thresholds, calls:
|
||||||
|
|
||||||
- `adapter.send(chat_id, text)` — first time a bubble is created.
|
- `adapter.send(chat_id, text)` — first time a bubble is created.
|
||||||
- `adapter.edit_message(chat_id, message_id, text)` — subsequent updates
|
- `adapter.edit_message(chat_id, message_id, text)` — subsequent updates
|
||||||
(each carries the **full** accumulated text).
|
(each carries the **full** accumulated text).
|
||||||
|
|
||||||
**Adapter → frames.**
|
**Adapter → frames.**
|
||||||
|
|
||||||
- First `send()` of a turn segment → `message.start {message_id, role}`.
|
- First `send()` of a turn segment → `message.start {message_id, role}`.
|
||||||
- Each `edit_message()` → `message.update {message_id, text}` (full text).
|
- Each `edit_message()` → `message.update {message_id, text}` (full text).
|
||||||
- Segment/turn finalization → `message.stop {message_id, final_text, reasoning?,
|
- 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
|
finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
|
||||||
while the user is at the bottom.
|
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:
|
**Streaming on/off.** Two levels:
|
||||||
|
|
||||||
- **Gateway side:** hermes `display.platforms.iris.streaming` (default
|
- **Gateway side:** hermes `display.platforms.iris.streaming` (default
|
||||||
follows global). When off, the app just gets one final `message` frame.
|
follows global). When off, the app just gets one final `message` frame.
|
||||||
- **App side (per device):** Settings → "Streaming" toggle (default on). When
|
- **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
|
**Gateway side.** hermes prepends reasoning to the final response when
|
||||||
`show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and
|
`show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and
|
||||||
chosen by `reasoning_style` (`gateway/display_config.py:37`):
|
chosen by `reasoning_style` (`gateway/display_config.py:37`):
|
||||||
|
|
||||||
- `code` (default): `💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>`
|
- `code` (default): `💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>`
|
||||||
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
|
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
|
||||||
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
|
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
|
||||||
|
|
||||||
**Plugin config.** Set for the `iris` platform:
|
**Plugin config.** Set for the `iris` platform:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
display:
|
display:
|
||||||
platforms:
|
platforms:
|
||||||
@@ -59,12 +100,14 @@ display:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Adapter split.** In `send()`, detect the `code`-style prefix and split:
|
**Adapter split.** In `send()`, detect the `code`-style prefix and split:
|
||||||
|
|
||||||
```
|
```
|
||||||
prefix = "💭 **Reasoning:**\n```\n"
|
prefix = "💭 **Reasoning:**\n```\n"
|
||||||
# find the closing "\n```\n\n" after the prefix
|
# find the closing "\n```\n\n" after the prefix
|
||||||
reasoning = text[len(prefix):close_idx]
|
reasoning = text[len(prefix):close_idx]
|
||||||
body = text[close_idx + len("\n```\n\n"):]
|
body = text[close_idx + len("\n```\n\n"):]
|
||||||
```
|
```
|
||||||
|
|
||||||
Emit `message {reasoning: <reasoning>, text: <body>, …}`. If no prefix is found
|
Emit `message {reasoning: <reasoning>, text: <body>, …}`. If no prefix is found
|
||||||
(reasoning off / no reasoning), emit `message {text: …}` with no `reasoning`.
|
(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 +
|
**Adapter → frames.** The adapter classifies tool activity (via turn-state +
|
||||||
line format) and emits **structured** frames — not pre-formatted strings:
|
line format) and emits **structured** frames — not pre-formatted strings:
|
||||||
|
|
||||||
- `tool.start {index, name, preview, args}` — a tool call began.
|
- `tool.start {index, name, preview, args}` — a tool call began.
|
||||||
- `tool.progress {index, name, note}` — in-progress update (optional).
|
- `tool.progress {index, name, note}` — in-progress update (optional).
|
||||||
- `tool.end {index, name, ok, duration, output_preview}` — completed.
|
- `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).
|
is reachable via search).
|
||||||
|
|
||||||
**App side — the verbosity setting** (Settings → "Tool detail"):
|
**App side — the verbosity setting** (Settings → "Tool detail"):
|
||||||
|
|
||||||
- **Everything** — show tool name, full args (collapsible), and output preview.
|
- **Everything** — show tool name, full args (collapsible), and output preview.
|
||||||
- **Truncated** (default) — show `emoji name: "short preview"` one-liner,
|
- **Truncated** (default) — show `emoji name: "short preview"` one-liner,
|
||||||
collapsible to expand.
|
collapsible to expand.
|
||||||
|
|||||||
Reference in new issue
Block a user