Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0951ebbbce | ||
|
|
a8c4418aff |
No files matched your search
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
// KCEF (desktop WebView backend) pulls jogamp artifacts from here.
|
||||
maven("https://jogamp.org/deployment/maven")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,12 +163,20 @@ fun MarkdownText(
|
||||
components = markdownComponents(
|
||||
codeFence = { model ->
|
||||
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
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 ->
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
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
|
||||
@@ -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
|
||||
@@ -869,6 +869,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
``MessageEvent``s for ``handle_message()``.
|
||||
"""
|
||||
|
||||
# WS has no message-size limit. The stream consumer resolves its per-chat
|
||||
# chunking budget via ``max_message_length_for_chat`` -> this attribute
|
||||
# (defaulting to 4096 when unset), which would split long replies — and
|
||||
# complete HTML artifacts — across multiple fence-reopened messages. A
|
||||
# large cap disables that chunking so a reply arrives as a single message.
|
||||
MAX_MESSAGE_LENGTH = 1_000_000
|
||||
|
||||
def __init__(self, config, **kwargs):
|
||||
platform = Platform("android")
|
||||
super().__init__(config=config, platform=platform)
|
||||
|
||||
Reference in new issue
Block a user