This commit is contained in:
7 files changed
+200
-25
No files matched your search
@@ -17,6 +17,9 @@ import kotlinx.serialization.Serializable
|
|||||||
* file that lives on the *gateway host* (agent-delivered `MEDIA:` directive).
|
* file that lives on the *gateway host* (agent-delivered `MEDIA:` directive).
|
||||||
* Null for locally-picked attachments.
|
* Null for locally-picked attachments.
|
||||||
* @param source Where the attachment originated.
|
* @param source Where the attachment originated.
|
||||||
|
* @param contentOffset For agent `MEDIA:` attachments, the char offset in the message's
|
||||||
|
* stripped text where the directive appeared, so the renderer can place it in-flow.
|
||||||
|
* Null means "append after the text".
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Attachment(
|
data class Attachment(
|
||||||
@@ -26,6 +29,7 @@ data class Attachment(
|
|||||||
val size: Long = 0,
|
val size: Long = 0,
|
||||||
val gatewayUrl: String? = null,
|
val gatewayUrl: String? = null,
|
||||||
val source: AttachmentSource = AttachmentSource.LOCAL,
|
val source: AttachmentSource = AttachmentSource.LOCAL,
|
||||||
|
val contentOffset: Int? = null,
|
||||||
) {
|
) {
|
||||||
val mediaKind: MediaKind
|
val mediaKind: MediaKind
|
||||||
get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri)
|
get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class ChatMediaDelegate(
|
|||||||
size = 0,
|
size = 0,
|
||||||
gatewayUrl = url,
|
gatewayUrl = url,
|
||||||
source = AttachmentSource.GATEWAY,
|
source = AttachmentSource.GATEWAY,
|
||||||
|
contentOffset = item.offset,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (newAttachments.isEmpty()) return
|
if (newAttachments.isEmpty()) return
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ internal fun mapServerMessages(
|
|||||||
size = 0,
|
size = 0,
|
||||||
gatewayUrl = url,
|
gatewayUrl = url,
|
||||||
source = AttachmentSource.GATEWAY,
|
source = AttachmentSource.GATEWAY,
|
||||||
|
contentOffset = item.offset,
|
||||||
)
|
)
|
||||||
}.takeIf { it.isNotEmpty() }
|
}.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ internal object HostMediaExtractor {
|
|||||||
data class Item(
|
data class Item(
|
||||||
val match: String, // the full matched directive (for stripping)
|
val match: String, // the full matched directive (for stripping)
|
||||||
val path: String, // normalized absolute path
|
val path: String, // normalized absolute path
|
||||||
|
// Issue #1367: char offset into strip(text) where the directive sat.
|
||||||
|
val offset: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Matches `MEDIA:<path>` (any extension), supporting quotes/backticks around the path. */
|
/** Matches `MEDIA:<path>` (any extension), supporting quotes/backticks around the path. */
|
||||||
@@ -33,12 +35,18 @@ internal object HostMediaExtractor {
|
|||||||
/** Extract every `MEDIA:<path>` directive from [text] as a normalized item. */
|
/** Extract every `MEDIA:<path>` directive from [text] as a normalized item. */
|
||||||
fun extract(text: String): List<Item> {
|
fun extract(text: String): List<Item> {
|
||||||
if (!text.contains("MEDIA:")) return emptyList()
|
if (!text.contains("MEDIA:")) return emptyList()
|
||||||
|
val total = strip(text).length
|
||||||
|
// Text kept so far, with every directive (valid or not) removed exactly as strip() does.
|
||||||
|
val kept = StringBuilder()
|
||||||
|
var cursor = 0
|
||||||
return MEDIA_TAG_RE
|
return MEDIA_TAG_RE
|
||||||
.findAll(text)
|
.findAll(text)
|
||||||
.mapNotNull { m ->
|
.mapNotNull { m ->
|
||||||
|
kept.append(text, cursor, m.range.first)
|
||||||
|
cursor = m.range.last + 1
|
||||||
val raw = m.groupValues.drop(1).firstOrNull { it.isNotBlank() } ?: return@mapNotNull null
|
val raw = m.groupValues.drop(1).firstOrNull { it.isNotBlank() } ?: return@mapNotNull null
|
||||||
val path = normalizePath(raw) ?: return@mapNotNull null
|
val path = normalizePath(raw) ?: return@mapNotNull null
|
||||||
Item(match = m.value, path = path)
|
Item(match = m.value, path = path, offset = strip(kept.toString()).length.coerceAtMost(total))
|
||||||
}.toList()
|
}.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
|
||||||
|
/** Issue #1367: one in-order piece of an agent message body. */
|
||||||
|
internal sealed interface MessageSegment {
|
||||||
|
data class Text(
|
||||||
|
val text: String,
|
||||||
|
) : MessageSegment
|
||||||
|
|
||||||
|
data class Media(
|
||||||
|
val attachment: Attachment,
|
||||||
|
) : MessageSegment
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interleave [attachments] into [content] at the position their `MEDIA:` directive occupied
|
||||||
|
* ([Attachment.contentOffset]). Attachments without an offset trail the text, as before.
|
||||||
|
*
|
||||||
|
* Offsets snap forward to the end of their line, and past a still-open code fence, so media
|
||||||
|
* never splits a sentence's markdown, list item, or fenced block.
|
||||||
|
*/
|
||||||
|
internal fun splitByMedia(
|
||||||
|
content: String,
|
||||||
|
attachments: List<Attachment>?,
|
||||||
|
): List<MessageSegment> {
|
||||||
|
if (attachments.isNullOrEmpty()) {
|
||||||
|
return if (content.isBlank()) emptyList() else listOf(MessageSegment.Text(content))
|
||||||
|
}
|
||||||
|
val inline = attachments.filter { it.contentOffset != null }.sortedBy { it.contentOffset }
|
||||||
|
val trailing = attachments.filter { it.contentOffset == null }
|
||||||
|
val out = mutableListOf<MessageSegment>()
|
||||||
|
var cursor = 0
|
||||||
|
inline.forEach { attachment ->
|
||||||
|
val pos = snapToBlockEnd(content, attachment.contentOffset ?: 0).coerceAtLeast(cursor)
|
||||||
|
addText(out, content.substring(cursor, pos))
|
||||||
|
out += MessageSegment.Media(attachment)
|
||||||
|
cursor = pos
|
||||||
|
}
|
||||||
|
addText(out, content.substring(cursor))
|
||||||
|
trailing.forEach { out += MessageSegment.Media(it) }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addText(
|
||||||
|
out: MutableList<MessageSegment>,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
if (text.isNotBlank()) out += MessageSegment.Text(text.trim('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapToBlockEnd(
|
||||||
|
content: String,
|
||||||
|
offset: Int,
|
||||||
|
): Int {
|
||||||
|
var pos = offset.coerceIn(0, content.length)
|
||||||
|
pos = lineEnd(content, pos)
|
||||||
|
// Inside an unclosed fence? Advance to the end of the closing fence line.
|
||||||
|
while (fenceOpenBefore(content, pos)) {
|
||||||
|
val next = pos + 1
|
||||||
|
if (next > content.length) return content.length
|
||||||
|
pos = lineEnd(content, next)
|
||||||
|
}
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lineEnd(
|
||||||
|
content: String,
|
||||||
|
from: Int,
|
||||||
|
): Int {
|
||||||
|
val idx = content.indexOf('\n', from)
|
||||||
|
return if (idx < 0) content.length else idx
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fenceOpenBefore(
|
||||||
|
content: String,
|
||||||
|
pos: Int,
|
||||||
|
): Boolean {
|
||||||
|
var open = false
|
||||||
|
content.substring(0, pos).lineSequence().forEach { line ->
|
||||||
|
if (line.trimStart().startsWith("```")) open = !open
|
||||||
|
}
|
||||||
|
return open
|
||||||
|
}
|
||||||
+39
-24
@@ -23,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -36,11 +37,13 @@ import com.m57.hermescontrol.R
|
|||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
import com.m57.hermescontrol.ui.chat.InlineAttachmentList
|
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
||||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||||
|
import com.m57.hermescontrol.ui.chat.MessageSegment
|
||||||
import com.m57.hermescontrol.ui.chat.TokenEstimator
|
import com.m57.hermescontrol.ui.chat.TokenEstimator
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
||||||
|
import com.m57.hermescontrol.ui.chat.splitByMedia
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,32 +99,44 @@ internal fun FullBleedAgentMessage(
|
|||||||
// lone Copy button). Blank rows are tool-call placeholders that slipped
|
// lone Copy button). Blank rows are tool-call placeholders that slipped
|
||||||
// through upstream mapping; the parent list renders the live status
|
// through upstream mapping; the parent list renders the live status
|
||||||
// indicator until the first visible delta lands.
|
// indicator until the first visible delta lands.
|
||||||
if (message.content.isNotBlank()) {
|
// Issue #1367: agent media renders where its `MEDIA:` directive sat in the
|
||||||
SelectionContainer {
|
// prose; attachments without a recorded position trail the text.
|
||||||
MarkdownText(
|
val segments =
|
||||||
text = message.content,
|
remember(message.content, message.attachments) {
|
||||||
textColor = textColor,
|
splitByMedia(message.content, message.attachments)
|
||||||
isStreaming = message.isStreaming,
|
}
|
||||||
searchQuery = searchQuery,
|
segments.forEach { segment ->
|
||||||
isCurrentMatch = isCurrentMatch,
|
when (segment) {
|
||||||
onImageClick = onImageClick,
|
is MessageSegment.Text -> {
|
||||||
)
|
SelectionContainer {
|
||||||
|
MarkdownText(
|
||||||
|
text = segment.text,
|
||||||
|
textColor = textColor,
|
||||||
|
isStreaming = message.isStreaming,
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
onImageClick = onImageClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MessageSegment.Media -> {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
InlineAttachment(
|
||||||
|
attachment = segment.attachment,
|
||||||
|
textColor = textColor,
|
||||||
|
onOpen = onOpenAttachment,
|
||||||
|
onSave = onSaveAttachment,
|
||||||
|
savingPath = savingAttachmentPath,
|
||||||
|
openingPath = openingAttachmentPath,
|
||||||
|
canSave = canSaveAttachment,
|
||||||
|
onImageClick = onImageClick,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline attachments (shared with UserBubble so agent-delivered
|
|
||||||
// media — images, files — shows in full-bleed mode too).
|
|
||||||
InlineAttachmentList(
|
|
||||||
attachments = message.attachments,
|
|
||||||
textColor = textColor,
|
|
||||||
onOpen = onOpenAttachment,
|
|
||||||
onSave = onSaveAttachment,
|
|
||||||
savingPath = savingAttachmentPath,
|
|
||||||
openingPath = openingAttachmentPath,
|
|
||||||
canSave = canSaveAttachment,
|
|
||||||
onImageClick = onImageClick,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!message.isStreaming && message.content.isNotBlank()) {
|
if (!message.isStreaming && message.content.isNotBlank()) {
|
||||||
val showTokenStat =
|
val showTokenStat =
|
||||||
messageStatsEnabled && showAssistantMessageTokens &&
|
messageStatsEnabled && showAssistantMessageTokens &&
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MediaSegmentsTest {
|
||||||
|
private fun att(
|
||||||
|
name: String,
|
||||||
|
offset: Int?,
|
||||||
|
) = Attachment(uri = "u/$name", name = name, mimeType = "image/png", contentOffset = offset)
|
||||||
|
|
||||||
|
private fun kinds(segments: List<MessageSegment>) =
|
||||||
|
segments.map {
|
||||||
|
when (it) {
|
||||||
|
is MessageSegment.Text -> "T:${it.text}"
|
||||||
|
is MessageSegment.Media -> "M:${it.attachment.name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractRecordsOffsetsIntoStrippedText() {
|
||||||
|
val text = "First: MEDIA:/a/1.png\nthen second: MEDIA:/a/2.png\nend"
|
||||||
|
val stripped = HostMediaExtractor.strip(text)
|
||||||
|
val items = HostMediaExtractor.extract(text)
|
||||||
|
assertEquals(2, items.size)
|
||||||
|
assertTrue(items[0].offset <= items[1].offset)
|
||||||
|
assertTrue(items[1].offset <= stripped.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mediaLandsBetweenTheParagraphsItSatBetween() {
|
||||||
|
val text = "Chart one:\nMEDIA:/a/1.png\nChart two:\nMEDIA:/a/2.png\nDone."
|
||||||
|
val stripped = HostMediaExtractor.strip(text)
|
||||||
|
val items = HostMediaExtractor.extract(text)
|
||||||
|
val atts = items.map { att(it.path.substringAfterLast('/'), it.offset) }
|
||||||
|
val out = kinds(splitByMedia(stripped, atts))
|
||||||
|
assertEquals(listOf("T:Chart one:", "M:1.png", "T:Chart two:", "M:2.png", "T:Done."), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noOffsetAppendsAfterText() {
|
||||||
|
val out = kinds(splitByMedia("Hello", listOf(att("x.png", null))))
|
||||||
|
assertEquals(listOf("T:Hello", "M:x.png"), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mediaOnlyMessageHasNoBlankText() {
|
||||||
|
val out = kinds(splitByMedia("", listOf(att("x.png", 0))))
|
||||||
|
assertEquals(listOf("M:x.png"), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mediaDoesNotSplitAFencedCodeBlock() {
|
||||||
|
val content = "Look:\n```\ncode line\nmore\n```\nafter"
|
||||||
|
val out = kinds(splitByMedia(content, listOf(att("x.png", content.indexOf("code line")))))
|
||||||
|
assertEquals(2, out.count { it.startsWith("T:") })
|
||||||
|
assertTrue(out[0].contains("more") && out[0].endsWith("```"))
|
||||||
|
assertEquals("M:x.png", out[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user