feat(chat): render images + attachments in assistant bubbles (#720)
Assistant messages could not display inline images or attachments on the mobile client. The markdown renderer had no image block, and AssistantBubble only rendered MarkdownText + timestamp, ignoring message.attachments (only UserBubble did). - Add MdBlock.Image + standalone  parsing in MarkdownText. Accepts http(s) gateway URLs and data:image/...;base64 inline media. - Render message.attachments inside AssistantBubble (mirrors UserBubble) via InlineAttachment / AsyncImage. - Add parser unit tests for image blocks (url, data: URL, inline-in-paragraph). Local verification: ./gradlew ktlintCheck compileDebugKotlin assembleDebug all green; app-debug.apk produced. Fixes feature gap where agent-delivered media (e.g. MEDIA:/ images sent to chat) was invisible in the Android app. Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
@@ -406,6 +406,18 @@ private fun AssistantBubble(
|
|||||||
isCurrentMatch = isCurrentMatch,
|
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) {
|
if (!message.isStreaming) {
|
||||||
Text(
|
Text(
|
||||||
text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)),
|
text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)),
|
||||||
|
|||||||
@@ -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 -> {
|
is MdBlock.DefList -> {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||||
block.items.forEach { item ->
|
block.items.forEach { item ->
|
||||||
@@ -345,6 +369,21 @@ fun MarkdownText(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a Markdown image: ``. 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
|
@Composable
|
||||||
private fun MarkdownTable(
|
private fun MarkdownTable(
|
||||||
block: MdBlock.Table,
|
block: MdBlock.Table,
|
||||||
@@ -514,6 +553,14 @@ internal fun parseBlocks(src: String): List<MdBlock> {
|
|||||||
items.forEachIndexed { idx, t -> blocks.add(MdBlock.Ordered(idx + 1, t)) }
|
items.forEachIndexed { idx, t -> blocks.add(MdBlock.Ordered(idx + 1, t)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standalone Markdown image:  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 -> {
|
else -> {
|
||||||
i = fallthroughToParagraph(lines, i, blocks)
|
i = fallthroughToParagraph(lines, i, blocks)
|
||||||
}
|
}
|
||||||
@@ -903,6 +950,11 @@ internal sealed interface MdBlock {
|
|||||||
val text: String,
|
val text: String,
|
||||||
) : MdBlock
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Image(
|
||||||
|
val uri: String,
|
||||||
|
val alt: String = "",
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
data class Quote(
|
data class Quote(
|
||||||
val text: String,
|
val text: String,
|
||||||
) : MdBlock
|
) : MdBlock
|
||||||
|
|||||||
@@ -194,4 +194,32 @@ class MarkdownTextFeatureTest {
|
|||||||
assertEquals("use val x = 1 here", an.toString())
|
assertEquals("use val x = 1 here", an.toString())
|
||||||
assertTrue(an.spanStyles.any { it.item.fontFamily != null })
|
assertTrue(an.spanStyles.any { it.item.fontFamily != null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 12. STANDALONE MARKDOWN IMAGE -> MdBlock.Image (agent-media rendering)
|
||||||
|
@Test
|
||||||
|
fun testStandaloneImage_parsesToImageBlock() {
|
||||||
|
val md = ""
|
||||||
|
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 = ""
|
||||||
|
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  for reference"
|
||||||
|
val blocks = parseBlocks(md)
|
||||||
|
assertTrue("should remain a paragraph", blocks.single() is MdBlock.Paragraph)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user