HTML artifacts: render agent-sent HTML/CSS/JS code blocks in an in-app WebView (Android platform WebView, desktop JCEF/KCEF) via a full-screen preview; artifact card with Preview button + code toggle, shown only once the message stops streaming; webview-multiplatform 1.9.40 + KCEF deps, detection + unit tests

This commit is contained in:
ARIA committed 2026-08-21 00:43:39 +02:00
1 parent a1814d016c
commit 7ba5632a03
11 files changed
+477 -1

No files matched your search

+13
View File
@@ -28,6 +28,16 @@ compose.desktop {
}
}
// M9: KCEF (JCEF/Chromium) for HTML artifact previews needs AWT reflection
// access on modern JDKs. Applied to the `run` task (JavaExec) and, below, to
// the jpackage `--java-options`.
afterEvaluate {
tasks.withType<JavaExec> {
jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
}
}
// ── M6: native packaging (docs/11 §11.4) ─────────────────────────────────
// `./gradlew :desktopApp:jpackage` builds a self-contained app image under
// build/jpackage (JRE bundled). `./gradlew :desktopApp:jpackage
@@ -78,5 +88,8 @@ tasks.register<Exec>("jpackage") {
"--icon", file("src/main/resources/icon.png").absolutePath,
"--dest", destDir.absolutePath,
"--java-options", "-Xmx1g",
// M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above).
"--java-options", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
"--java-options", "--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
)
}
+2
View File
@@ -10,6 +10,8 @@ dependencyResolutionManagement {
repositories {
mavenCentral()
google()
// KCEF (desktop WebView backend) pulls jogamp artifacts from here.
maven("https://jogamp.org/deployment/maven")
}
}
+14
View File
@@ -10,6 +10,11 @@ val composeVersion = "1.7.3"
val coroutinesVersion = "1.9.0"
val serializationVersion = "1.7.3"
val okhttpVersion = "4.12.0"
// Pinned to 1.9.40: the last release built against Compose 1.7.x (1.7.1);
// 2.0.x moves to Compose 1.8+. HTML artifact previews (M9): Android uses the
// platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use).
val webviewVersion = "1.9.40"
val kcefVersion = "2024.04.20.3"
// Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x
// (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore,
// and the -code module for language-aware syntax highlighting (Highlights).
@@ -45,10 +50,19 @@ kotlin {
implementation("com.mikepenz:multiplatform-markdown-renderer:$markdownVersion")
implementation("com.mikepenz:multiplatform-markdown-renderer-m3:$markdownVersion")
implementation("com.mikepenz:multiplatform-markdown-renderer-code:$markdownVersion")
// M9: HTML artifact previews — WebView composable (platform WebView
// on Android, KCEF/JCEF on desktop).
implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
// M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The
// webview library exposes it transitively, but we reference KCEF
// directly (init + progress) so declare it explicitly.
desktopMain.dependencies {
implementation("dev.datlag:kcef:$kcefVersion")
}
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
androidMain.dependencies {
// M7: EncryptedSharedPreferences for the pairing token
@@ -0,0 +1,39 @@
package iris.platform
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
/**
* M9: HTML artifact preview on Android — the platform WebView (JavaScript
* enabled by default in the library's WebSettings).
*/
@Composable
actual fun PlatformWebView(html: String, modifier: Modifier) {
val state = rememberWebViewStateWithHTMLData(data = html)
WebView(state, modifier = modifier)
}
@Composable
actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val currentOnBack = rememberUpdatedState(onBack)
DisposableEffect(dispatcher) {
if (dispatcher != null) {
val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
currentOnBack.value()
}
}
dispatcher.addCallback(callback)
onDispose { callback.remove() }
}
onDispose { }
}
return this
}
@@ -17,6 +17,7 @@ import iris.data.SecureStore
import iris.net.GatewayClient
import iris.platform.setActiveController
import iris.state.IrisController
import iris.ui.ArtifactPreviewOverlay
import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen
import iris.ui.theme.IrisColors
@@ -96,6 +97,9 @@ fun IrisApp(
// without blocking the view (M7's full-screen spinner is gone).
else -> ChatScreen(controller)
}
// M9: full-screen HTML artifact preview (opened from an
// artifact card in the chat); covers everything while open.
ArtifactPreviewOverlay()
}
}
}
@@ -0,0 +1,18 @@
package iris.platform
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* M9: renders [html] in an in-app WebView with JavaScript enabled.
*
* Platform actuals: Android → the platform WebView; desktop → JCEF (KCEF),
* whose Chromium binary is downloaded on first use (the caller shows a
* progress indicator until it is ready).
*/
@Composable
expect fun PlatformWebView(html: String, modifier: Modifier)
/** Consumes the system back button (Android) while applied; no-op elsewhere. */
@Composable
expect fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier
@@ -0,0 +1,212 @@
package iris.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.CodeOff
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.snipme.highlights.Highlights
import iris.platform.PlatformWebView
import iris.platform.handleSystemBack
import iris.ui.theme.IrisColors
/**
* M9: HTML artifact support. When the agent emits a complete HTML document
* (HTML + CSS + JS) in a fenced code block, the bubble renders an artifact
* card instead of plain highlighted code. "Preview" opens the document in a
* full-screen in-app WebView with JavaScript enabled, so interactive scripts
* run inside the app.
*/
/**
* True when a fenced code block should be rendered as a runnable HTML
* artifact: an explicit ```html / ```htm language tag, or an untagged block
* that starts with a complete HTML document.
*/
fun isHtmlArtifact(language: String?, code: String): Boolean {
val lang = language?.trim()?.lowercase()
if (lang == "html" || lang == "htm") return true
if (lang != null) return false
val head = code.trimStart().take(64).lowercase()
return head.startsWith("<!doctype html") || head.startsWith("<html")
}
/** The document's <title> (card / overlay header), or null when absent. */
fun htmlTitle(code: String): String? =
TITLE_REGEX.find(code)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
private val TITLE_REGEX =
Regex("<title[^>]*>(.*?)</title>", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
/** The artifact currently open in the full-screen preview (null = closed). */
data class ArtifactPreviewData(val html: String, val title: String?)
/**
* Process-wide preview state: the artifact card (deep in the message list)
* writes here, the overlay at the app root reads it.
*/
object ArtifactPreview {
private val _current = mutableStateOf<ArtifactPreviewData?>(null)
val current: State<ArtifactPreviewData?> get() = _current
fun show(html: String, title: String?) {
_current.value = ArtifactPreviewData(html, title)
}
fun hide() {
_current.value = null
}
}
/**
* Artifact card shown in place of a highlighted HTML code block: title row
* with a Preview button (opens the full-screen overlay) and a code toggle
* (reveals the highlighted source with its copy button).
*/
@Composable
fun HtmlArtifactCard(
code: String,
style: TextStyle,
highlights: Highlights.Builder,
) {
val title = remember(code) { htmlTitle(code) }
var showCode by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = IrisColors.panel,
) {
Row(
modifier = Modifier.padding(start = 12.dp, top = 6.dp, bottom = 6.dp, end = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Code,
contentDescription = null,
tint = IrisColors.statusGreen,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title ?: "HTML artifact",
color = IrisColors.textBright,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "Interactive HTML preview",
color = IrisColors.textMuted,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Button(
onClick = { ArtifactPreview.show(code, title) },
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 12.dp,
vertical = 4.dp,
),
) {
Text("Preview", style = MaterialTheme.typography.bodyMedium)
}
IconButton(onClick = { showCode = !showCode }) {
Icon(
imageVector = if (showCode) Icons.Filled.CodeOff else Icons.Filled.Code,
contentDescription = if (showCode) "Hide code" else "Show code",
tint = IrisColors.textSecondary,
)
}
}
}
if (showCode) {
Spacer(modifier = Modifier.height(6.dp))
CodeBlockWithCopy(code = code, language = "html", style = style, highlights = highlights)
}
}
}
/**
* Full-screen artifact preview overlay (rendered at the app root). Covers the
* whole window; the header close button and the system back button (Android)
* dismiss it.
*/
@Composable
fun ArtifactPreviewOverlay() {
val preview by ArtifactPreview.current
if (preview == null) return
val data = preview!!
Box(
modifier = Modifier
.fillMaxSize()
.background(IrisColors.background)
.handleSystemBack { ArtifactPreview.hide() },
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 2.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Code,
contentDescription = null,
tint = IrisColors.statusGreen,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = data.title ?: "HTML preview",
modifier = Modifier.weight(1f),
color = IrisColors.textBright,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
IconButton(onClick = { ArtifactPreview.hide() }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close preview",
tint = IrisColors.textSecondary,
)
}
}
HorizontalDivider(color = IrisColors.divider)
PlatformWebView(html = data.html, modifier = Modifier.fillMaxSize().weight(1f))
}
}
}
@@ -60,6 +60,10 @@ fun MarkdownText(
color: Color,
fontSize: TextUnit,
modifier: Modifier = Modifier,
// M9: while the message is still streaming, HTML code blocks render as
// plain highlighted code — the artifact card (and its runnable preview)
// only appears once the message is complete.
isStreaming: Boolean = false,
) {
val state = rememberMarkdownState(text)
// The app is always dark-themed, so force the dark highlight palette
@@ -159,13 +163,21 @@ fun MarkdownText(
components = markdownComponents(
codeFence = { model ->
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
}
}
},
codeBlock = { model ->
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
}
}
},
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
// Material 3 checkbox instead.
@@ -184,9 +196,10 @@ fun MarkdownText(
/**
* 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.
* Also used by [HtmlArtifactCard] to reveal the source of an HTML artifact.
*/
@Composable
private fun CodeBlockWithCopy(
internal fun CodeBlockWithCopy(
code: String,
language: String?,
style: TextStyle,
@@ -1921,6 +1921,7 @@ private fun MessageBubble(
if (msg.streaming) " ▉" else "",
color = textColor,
fontSize = 15.sp,
isStreaming = msg.streaming,
)
}
// M7: timestamp + delivery status on their own line at the
@@ -1963,6 +1964,7 @@ private fun MessageBubble(
color = textColor,
fontSize = if (isCommentary) 13.sp else 15.sp,
modifier = Modifier.fillMaxWidth(),
isStreaming = msg.streaming,
)
}
}
@@ -0,0 +1,65 @@
package iris.ui
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class HtmlArtifactTest {
@Test
fun htmlLanguageTagIsArtifact() {
assertTrue(isHtmlArtifact("html", "<p>hi</p>"))
assertTrue(isHtmlArtifact("htm", "<p>hi</p>"))
assertTrue(isHtmlArtifact("HTML", "<p>hi</p>"))
assertTrue(isHtmlArtifact(" html ", "<p>hi</p>"))
}
@Test
fun otherLanguageTagsAreNotArtifacts() {
assertFalse(isHtmlArtifact("css", "<style>x</style>"))
assertFalse(isHtmlArtifact("js", "console.log(1)"))
assertFalse(isHtmlArtifact("kotlin", "fun main() {}"))
// Even a full document inside a non-html fence is not an artifact.
assertFalse(isHtmlArtifact("text", "<!DOCTYPE html><html></html>"))
}
@Test
fun untaggedCompleteDocumentIsArtifact() {
assertTrue(isHtmlArtifact(null, "<!DOCTYPE html>\n<html><body>hi</body></html>"))
assertTrue(isHtmlArtifact(null, " <html><body>hi</body></html>"))
assertTrue(isHtmlArtifact(null, "<HTML><BODY>hi</BODY></HTML>"))
}
@Test
fun untaggedFragmentIsNotArtifact() {
assertFalse(isHtmlArtifact(null, "<div>just a fragment</div>"))
assertFalse(isHtmlArtifact(null, "Some prose with <b>bold</b> inside."))
assertFalse(isHtmlArtifact(null, ""))
}
@Test
fun extractsTitle() {
assertEquals(
"My Page",
htmlTitle("<html><head><title>My Page</title></head><body></body></html>"),
)
assertEquals(
"Spaced",
htmlTitle("<title> Spaced </title>"),
)
// Case-insensitive, attributes allowed.
assertEquals(
"X",
htmlTitle("<TITLE lang=\"en\">X</TITLE>"),
)
}
@Test
fun missingOrEmptyTitleIsNull() {
assertNull(htmlTitle("<html><body>no title</body></html>"))
assertNull(htmlTitle("<title></title>"))
assertNull(htmlTitle("<title> </title>"))
}
}
@@ -0,0 +1,94 @@
package iris.platform
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
import dev.datlag.kcef.KCEF
import iris.ui.theme.IrisColors
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
/**
* M9: HTML artifact preview on desktop — JCEF (Chromium) via KCEF.
*
* KCEF is initialized once per process, lazily on the first artifact
* preview. The JCEF/Chromium binary is downloaded from the pinned
* JetBrainsRuntime release on first use and cached in ~/.iris/kcef-bundle,
* so later starts (and the packaged app) reuse it.
*/
private val kcefReady = MutableStateFlow(false)
private val kcefProgress = MutableStateFlow(0f)
private val kcefError = MutableStateFlow<String?>(null)
private suspend fun ensureKcefInitialized() {
if (kcefReady.value) return
withContext(Dispatchers.IO) {
KCEF.init(
builder = {
installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle"))
// Pinned to the JBR 17 release the KCEF 2024.04.20.x line was
// built against; the default "latest" JBR may be JDK 21-based
// and is not guaranteed compatible.
download {
github { release("jbr-release-17.0.12b1207.37") }
}
settings {
cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath
}
progress {
onDownloading { kcefProgress.value = it }
onInitialized { kcefReady.value = true }
}
},
onError = { kcefError.value = it?.message ?: "Preview engine failed to start" },
onRestartRequired = { kcefError.value = "Restart the app to finish setting up previews" },
)
}
}
@Composable
actual fun PlatformWebView(html: String, modifier: Modifier) {
val ready by kcefReady.collectAsState()
val progress by kcefProgress.collectAsState()
val error by kcefError.collectAsState()
LaunchedEffect(Unit) { ensureKcefInitialized() }
Box(modifier = modifier.fillMaxSize()) {
when {
error != null -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(error!!, color = IrisColors.errorText)
}
}
!ready -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(progress = { progress })
Text(
text = "Preparing preview… ${progress.toInt()}%",
color = IrisColors.textSecondary,
)
}
}
}
else -> {
val state = rememberWebViewStateWithHTMLData(data = html)
WebView(state, modifier = Modifier.fillMaxSize())
}
}
}
}
@Composable
actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier = this