diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index a586b15b..0608c401 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -406,6 +406,18 @@ private fun AssistantBubble( isCurrentMatch = isCurrentMatch, ) } + // Render inline attachments (mirrors UserBubble so agent-delivered + // media — images, files — shows in assistant bubbles too). + if (!message.attachments.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + message.attachments.forEach { attachment -> + InlineAttachment( + attachment = attachment, + textColor = textColor, + ) + Spacer(modifier = Modifier.height(4.dp)) + } + } if (!message.isStreaming) { Text( text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index b2573442..c48762d7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -264,6 +264,30 @@ fun MarkdownText( } } + is MdBlock.Image -> { + // AsyncImage is a Compose runtime composable — load it lazily + // behind remember so the parser stays pure and testable (the + // parseBlocks/parseInline unit tests never touch Compose). + val model: Any = block.uri + androidx.compose.foundation.layout.Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .padding(vertical = 4.dp), + ) { + coil.compose.AsyncImage( + model = model, + contentDescription = block.alt.ifBlank { null }, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)), + contentScale = androidx.compose.ui.layout.ContentScale.FillWidth, + ) + } + } + is MdBlock.DefList -> { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { block.items.forEach { item -> @@ -345,6 +369,21 @@ fun MarkdownText( } } +/** + * Matches a Markdown image: `![alt](uri)`. The `uri` may be an http(s) URL + * (gateway-served media the phone can reach), a `data:image/...;base64,...` + * data URL (e.g. agent-delivered inline media), or any other resolvable model + * string (Coil handles all three). Bare `![]()` with empty uri is skipped. + */ +private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""") + +private fun tryParseImage(line: String): MdBlock.Image? { + val m = IMAGE_RE.matchAt(line, 0) ?: return null + val uri = m.groupValues[2].trim() + if (uri.isEmpty()) return null + return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim()) +} + @Composable private fun MarkdownTable( block: MdBlock.Table, @@ -514,6 +553,14 @@ internal fun parseBlocks(src: String): List { items.forEachIndexed { idx, t -> blocks.add(MdBlock.Ordered(idx + 1, t)) } } + // Standalone Markdown image: ![alt](uri) on its own line. + // Inline images inside a paragraph are left as-is (rendered as text) + // to keep scope tight; standalone is the common agent-media case. + line.isNotBlank() && tryParseImage(line) != null -> { + blocks.add(tryParseImage(line)!!) + i++ + } + else -> { i = fallthroughToParagraph(lines, i, blocks) } @@ -903,6 +950,11 @@ internal sealed interface MdBlock { val text: String, ) : MdBlock + data class Image( + val uri: String, + val alt: String = "", + ) : MdBlock + data class Quote( val text: String, ) : MdBlock diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index 14debb08..2f7ab71b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -194,4 +194,32 @@ class MarkdownTextFeatureTest { assertEquals("use val x = 1 here", an.toString()) assertTrue(an.spanStyles.any { it.item.fontFamily != null }) } + + // 12. STANDALONE MARKDOWN IMAGE -> MdBlock.Image (agent-media rendering) + @Test + fun testStandaloneImage_parsesToImageBlock() { + val md = "![cute cat](https://example.com/cat.jpg)" + val block = parseBlocks(md).singleOrNull() as? MdBlock.Image + assertTrue("expected a single Image block", block != null) + assertEquals("https://example.com/cat.jpg", block!!.uri) + assertEquals("cute cat", block.alt) + } + + // 12b. data: URL (base64 inline media) also accepted as an image uri + @Test + fun testImage_dataUrl_accepted() { + val uri = "data:image/jpeg;base64,/9j/abc" + val md = "![pic]($uri)" + val block = parseBlocks(md).singleOrNull() as? MdBlock.Image + assertTrue("data: URL should parse to Image block", block != null) + assertEquals(uri, block!!.uri) + } + + // 12c. inline image inside a paragraph is left as text (scope guard) + @Test + fun testInlineImage_inParagraph_staysText() { + val md = "see this ![cat](https://x/cat.png) for reference" + val blocks = parseBlocks(md) + assertTrue("should remain a paragraph", blocks.single() is MdBlock.Paragraph) + } }