Compare commits
2
Commits
8100730cba
...
0951ebbbce
| 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) ─────────────────────────────────
|
// ── M6: native packaging (docs/11 §11.4) ─────────────────────────────────
|
||||||
// `./gradlew :desktopApp:jpackage` builds a self-contained app image under
|
// `./gradlew :desktopApp:jpackage` builds a self-contained app image under
|
||||||
// build/jpackage (JRE bundled). `./gradlew :desktopApp:jpackage
|
// build/jpackage (JRE bundled). `./gradlew :desktopApp:jpackage
|
||||||
@@ -78,5 +88,8 @@ tasks.register<Exec>("jpackage") {
|
|||||||
"--icon", file("src/main/resources/icon.png").absolutePath,
|
"--icon", file("src/main/resources/icon.png").absolutePath,
|
||||||
"--dest", destDir.absolutePath,
|
"--dest", destDir.absolutePath,
|
||||||
"--java-options", "-Xmx1g",
|
"--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 {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
google()
|
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 coroutinesVersion = "1.9.0"
|
||||||
val serializationVersion = "1.7.3"
|
val serializationVersion = "1.7.3"
|
||||||
val okhttpVersion = "4.12.0"
|
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
|
// 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,
|
// (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore,
|
||||||
// and the -code module for language-aware syntax highlighting (Highlights).
|
// 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:$markdownVersion")
|
||||||
implementation("com.mikepenz:multiplatform-markdown-renderer-m3:$markdownVersion")
|
implementation("com.mikepenz:multiplatform-markdown-renderer-m3:$markdownVersion")
|
||||||
implementation("com.mikepenz:multiplatform-markdown-renderer-code:$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 {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
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).
|
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
// M7: EncryptedSharedPreferences for the pairing token
|
// 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.net.GatewayClient
|
||||||
import iris.platform.setActiveController
|
import iris.platform.setActiveController
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
|
import iris.ui.ArtifactPreviewOverlay
|
||||||
import iris.ui.screens.ChatScreen
|
import iris.ui.screens.ChatScreen
|
||||||
import iris.ui.screens.ConnectScreen
|
import iris.ui.screens.ConnectScreen
|
||||||
import iris.ui.theme.IrisColors
|
import iris.ui.theme.IrisColors
|
||||||
@@ -96,6 +97,9 @@ fun IrisApp(
|
|||||||
// without blocking the view (M7's full-screen spinner is gone).
|
// without blocking the view (M7's full-screen spinner is gone).
|
||||||
else -> ChatScreen(controller)
|
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,
|
color: Color,
|
||||||
fontSize: TextUnit,
|
fontSize: TextUnit,
|
||||||
modifier: Modifier = Modifier,
|
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)
|
val state = rememberMarkdownState(text)
|
||||||
// The app is always dark-themed, so force the dark highlight palette
|
// The app is always dark-themed, so force the dark highlight palette
|
||||||
@@ -159,13 +163,21 @@ fun MarkdownText(
|
|||||||
components = markdownComponents(
|
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)) {
|
||||||
|
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||||
|
} else {
|
||||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
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)) {
|
||||||
|
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||||
|
} else {
|
||||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// 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.
|
||||||
@@ -184,9 +196,10 @@ fun MarkdownText(
|
|||||||
/**
|
/**
|
||||||
* 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.
|
||||||
|
* Also used by [HtmlArtifactCard] to reveal the source of an HTML artifact.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun CodeBlockWithCopy(
|
internal fun CodeBlockWithCopy(
|
||||||
code: String,
|
code: String,
|
||||||
language: String?,
|
language: String?,
|
||||||
style: TextStyle,
|
style: TextStyle,
|
||||||
|
|||||||
@@ -1921,6 +1921,7 @@ private fun MessageBubble(
|
|||||||
if (msg.streaming) " ▉" else "",
|
if (msg.streaming) " ▉" else "",
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
|
isStreaming = msg.streaming,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// M7: timestamp + delivery status on their own line at the
|
// M7: timestamp + delivery status on their own line at the
|
||||||
@@ -1963,6 +1964,7 @@ private fun MessageBubble(
|
|||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()``.
|
``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):
|
def __init__(self, config, **kwargs):
|
||||||
platform = Platform("android")
|
platform = Platform("android")
|
||||||
super().__init__(config=config, platform=platform)
|
super().__init__(config=config, platform=platform)
|
||||||
|
|||||||
Reference in new issue
Block a user