refactor: extract media + server-message-mapping from ChatViewModel (#955)
* refactor: extract ChatMediaDelegate from ChatViewModel (verbatim move) * refactor: extract mapServerMessages to ChatServerMessageMapper (verbatim move) * style: remove orphaned comments after media extraction, ktlint format --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
bb400881e6
commit
69578e7818
3 files changed
+522
-459
No files matched your search
@@ -0,0 +1,274 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.Application
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.remote.GatewayFile
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileResult
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Owns gateway/host media handling for [ChatViewModel]: attaching MEDIA paths
|
||||
* extracted from message content, opening and saving attachments through the
|
||||
* gateway file client, and the FileProvider/ACTION_VIEW plumbing.
|
||||
*
|
||||
* Verbatim extraction of the inline implementation (corral 1 of the 2026-08
|
||||
* ChatViewModel delegate refactor) — same collaborators, same behavior: the
|
||||
* shared `_uiState` flow, an application context provider, and a scope for
|
||||
* background fetches are supplied by the ViewModel at construction.
|
||||
*/
|
||||
class ChatMediaDelegate(
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
private val getApplication: () -> Application,
|
||||
private val scope: kotlinx.coroutines.CoroutineScope,
|
||||
private val ioDispatcher: kotlin.coroutines.CoroutineContext,
|
||||
) {
|
||||
fun attachHostMedia(
|
||||
sessionId: String,
|
||||
messageId: String,
|
||||
) {
|
||||
val current = uiState.value.messages.find { it.id == messageId } ?: return
|
||||
val content = current.content
|
||||
val items = HostMediaExtractor.extract(content)
|
||||
if (items.isEmpty()) return
|
||||
|
||||
val baseUrl = AuthManager.getBaseUrl()
|
||||
if (baseUrl.isBlank()) return
|
||||
// NOTE: no token gate here. Gated (basic-auth) dashboards download via
|
||||
// the session cookie, and even loopback hosts can serve files with an
|
||||
// empty query token — requiring a non-blank token dropped every MEDIA
|
||||
// attachment on gate-auth connections where getToken() returns null.
|
||||
|
||||
val existingUrls =
|
||||
current.attachments
|
||||
.orEmpty()
|
||||
.mapNotNull { it.gatewayUrl }
|
||||
.toSet()
|
||||
val newAttachments =
|
||||
items.mapNotNull { item ->
|
||||
val token = AuthManager.getToken().orEmpty()
|
||||
val url = GatewayFileClient.buildMediaUrl(baseUrl, token, item.path) ?: return@mapNotNull null
|
||||
if (url in existingUrls) return@mapNotNull null
|
||||
Attachment(
|
||||
uri = url,
|
||||
name = mediaNameFromPath(item.path),
|
||||
mimeType = mediaMimeForPath(item.path),
|
||||
size = 0,
|
||||
gatewayUrl = url,
|
||||
source = AttachmentSource.GATEWAY,
|
||||
)
|
||||
}
|
||||
if (newAttachments.isEmpty()) return
|
||||
|
||||
val stripped = HostMediaExtractor.strip(content)
|
||||
uiState.update { state ->
|
||||
state.copy(
|
||||
messages =
|
||||
state.messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(
|
||||
content = stripped,
|
||||
attachments =
|
||||
(msg.attachments.orEmpty() + newAttachments)
|
||||
.distinctBy { it.gatewayUrl ?: it.uri },
|
||||
)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an attachment when its chip/thumbnail is tapped.
|
||||
*
|
||||
* - LOCAL (user-picked) files: open the original `content://` URI
|
||||
* directly via [android.content.Intent.ACTION_VIEW] — the resolver
|
||||
* already grants read access for the picked document. If that fails
|
||||
* (e.g. the permission lapsed), we copy to cache and retry via
|
||||
* FileProvider so the tap is never a silent no-op.
|
||||
* - GATEWAY (agent `MEDIA:`) files: stream the file to cache via
|
||||
* [GatewayFileClient] (chunked — never held in memory), then open with
|
||||
* [android.content.Intent.ACTION_VIEW] through FileProvider — so a
|
||||
* remote phone can view agent-delivered files in-place.
|
||||
*
|
||||
* Failures surface through [ChatUiState.openError] (non-blocking
|
||||
* snackbar); the tap is never swallowed.
|
||||
*/
|
||||
fun openAttachment(attachment: Attachment) {
|
||||
val ctx = getApplication().applicationContext
|
||||
if (attachment.source == AttachmentSource.LOCAL) {
|
||||
// Best-effort direct open of the picked content URI.
|
||||
runCatching { openWithView(ctx, android.net.Uri.parse(attachment.uri), attachment.mimeType) }
|
||||
.onSuccess { return }
|
||||
.onFailure { /* fall through to cache-copy below */ }
|
||||
}
|
||||
// GATEWAY, or LOCAL direct-open failed → fetch/copy then open.
|
||||
val path = gatewayPathFor(attachment)
|
||||
// Show a loading indicator on the attachment card while the file
|
||||
// streams down (mirrors the save spinner; cleared in all outcomes).
|
||||
uiState.update { it.copy(openingAttachmentPath = path) }
|
||||
val cacheDir = java.io.File(ctx.cacheDir, "gateway_files")
|
||||
scope.launch(ioDispatcher) {
|
||||
try {
|
||||
when (val result = GatewayFileClient.fetch(path, cacheDir)) {
|
||||
is GatewayFileResult.Success -> {
|
||||
openBytes(ctx, result.file)
|
||||
}
|
||||
|
||||
is GatewayFileResult.NotFound -> {
|
||||
showOpenError("File not found on gateway: ${attachment.name}")
|
||||
}
|
||||
|
||||
is GatewayFileResult.Forbidden -> {
|
||||
showOpenError("Access denied: ${attachment.name}")
|
||||
}
|
||||
|
||||
is GatewayFileResult.TooLarge -> {
|
||||
showOpenError("File too large to open: ${attachment.name}")
|
||||
}
|
||||
|
||||
is GatewayFileResult.Unauthorized -> {
|
||||
showOpenError("Session expired — reconnect to open: ${attachment.name}")
|
||||
}
|
||||
|
||||
is GatewayFileResult.Failure -> {
|
||||
showOpenError("Could not open ${attachment.name}: ${result.throwable.message}")
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
uiState.update {
|
||||
if (it.openingAttachmentPath == path) it.copy(openingAttachmentPath = null) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Save an agent-delivered file through Android's system document picker. */
|
||||
fun saveAttachment(
|
||||
attachment: Attachment,
|
||||
destination: android.net.Uri,
|
||||
) {
|
||||
if (uiState.value.savingAttachmentPath != null) return
|
||||
val path = gatewayPathFor(attachment)
|
||||
val cacheDir =
|
||||
java.io.File(getApplication().applicationContext.cacheDir, "gateway_files")
|
||||
uiState.update { it.copy(savingAttachmentPath = path) }
|
||||
scope.launch(ioDispatcher) {
|
||||
try {
|
||||
when (val result = GatewayFileClient.fetch(path, cacheDir)) {
|
||||
is GatewayFileResult.Success -> {
|
||||
val resolver = getApplication().contentResolver
|
||||
runCatching {
|
||||
resolver
|
||||
.openOutputStream(destination, "wt")
|
||||
?.use { output ->
|
||||
result.file.cacheFile.inputStream().use { input ->
|
||||
GatewayFileClient.copyChunked(input, output)
|
||||
}
|
||||
}
|
||||
?: error("destination is unavailable")
|
||||
}.onSuccess {
|
||||
showOpenError("Saved ${result.file.name}")
|
||||
}.onFailure {
|
||||
showOpenError("Could not save ${attachment.name}: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
is GatewayFileResult.NotFound -> showOpenError("File not found on gateway: ${attachment.name}")
|
||||
is GatewayFileResult.Forbidden -> showOpenError("Access denied: ${attachment.name}")
|
||||
is GatewayFileResult.TooLarge -> showOpenError("File too large to save: ${attachment.name}")
|
||||
is GatewayFileResult.Unauthorized ->
|
||||
showOpenError(
|
||||
"Session expired — reconnect to save: ${attachment.name}",
|
||||
)
|
||||
is GatewayFileResult.Failure ->
|
||||
showOpenError("Could not save ${attachment.name}: ${result.throwable.message}")
|
||||
}
|
||||
} finally {
|
||||
uiState.update {
|
||||
if (it.savingAttachmentPath == path) {
|
||||
it.copy(savingAttachmentPath = null)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun gatewayPathFor(attachment: Attachment): String =
|
||||
attachment.gatewayUrl?.let(::gatewayPathFromUrl)
|
||||
?: attachment.uri.removePrefix("gateway:").takeIf { it != attachment.uri }
|
||||
?: attachment.name
|
||||
|
||||
/** Open a gateway file already streamed to cache via FileProvider + ACTION_VIEW. */
|
||||
private fun openBytes(
|
||||
ctx: android.content.Context,
|
||||
file: GatewayFile,
|
||||
) {
|
||||
runCatching {
|
||||
val uri =
|
||||
androidx.core.content.FileProvider.getUriForFile(
|
||||
ctx,
|
||||
"${ctx.packageName}.fileprovider",
|
||||
file.cacheFile,
|
||||
)
|
||||
openWithView(ctx, uri, file.mimeType)
|
||||
}.onFailure { showOpenError("Could not open ${file.name}: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Fire an ACTION_VIEW intent; throws if no activity can handle the type. */
|
||||
private fun openWithView(
|
||||
ctx: android.content.Context,
|
||||
uri: android.net.Uri,
|
||||
mimeType: String,
|
||||
) {
|
||||
val viewIntent =
|
||||
android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mimeType.ifBlank { "*/*" })
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
try {
|
||||
ctx.startActivity(viewIntent)
|
||||
} catch (e: Throwable) {
|
||||
val fallbackIntent =
|
||||
android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "*/*")
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
val chooser =
|
||||
android.content.Intent.createChooser(fallbackIntent, "Open file").apply {
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
ctx.startActivity(chooser)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showOpenError(message: String) {
|
||||
uiState.update { it.copy(openError = message) }
|
||||
}
|
||||
|
||||
fun clearOpenError() {
|
||||
uiState.update { it.copy(openError = null) }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sameMessages(
|
||||
left: List<ChatMessage>,
|
||||
right: List<ChatMessage>,
|
||||
): Boolean =
|
||||
left.size == right.size &&
|
||||
left.zip(right).all { (a, b) ->
|
||||
a.id == b.id &&
|
||||
a.role == b.role &&
|
||||
a.content == b.content &&
|
||||
a.reasoningText == b.reasoningText
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
|
||||
/**
|
||||
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
|
||||
*
|
||||
* Verbatim extraction of ChatViewModel.mapServerMessages (corral 2 of the
|
||||
* 2026-08 delegate refactor). Pure with respect to the VM: it reads the live
|
||||
* message list supplied by the caller and one paging-mode flag — no state
|
||||
* mutation, no I/O beyond URL construction.
|
||||
*/
|
||||
internal fun mapServerMessages(
|
||||
sessionId: String,
|
||||
messages: List<SessionMessage>,
|
||||
offset: Int,
|
||||
latestPaging: Boolean,
|
||||
liveMessages: List<ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
val existingReasoningMap =
|
||||
liveMessages
|
||||
.filter { it.reasoningText.isNotBlank() }
|
||||
.associateBy { it.content }
|
||||
|
||||
// Tool rows in the REST transcript carry NO tool name — the live WS
|
||||
// stream was the only source of `toolName`. Match each REST tool row
|
||||
// to its WS counterpart by RESULT CONTENT (not position — pagination
|
||||
// and mixed cache state make positional mapping misalign, leaving
|
||||
// the newest call with a null name → generic "tool" bubble). When a
|
||||
// match is found the live message is reused wholesale (same id +
|
||||
// toolName + rich payload), so persistence upserts the same row
|
||||
// instead of accumulating a second `rest-` copy in Room. Issue #771.
|
||||
val liveToolByResult = linkedMapOf<String, ChatMessage>()
|
||||
liveMessages
|
||||
.filter { it.role == MessageRole.TOOL }
|
||||
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
|
||||
.forEach { msg ->
|
||||
canonicalToolResultKey(msg.content)?.let { key ->
|
||||
liveToolByResult.putIfAbsent(key, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #842: REST transcript rows carry the gateway's `tool_call_id`
|
||||
// — prefer matching live bubbles by that 1:1 identity. It works for
|
||||
// EVERY tool shape, including MCP/web rows whose REST copy is raw
|
||||
// `<untrusted_tool_result>` text with no JSON key to canonicalize.
|
||||
val liveToolByCallId = linkedMapOf<String, ChatMessage>()
|
||||
liveMessages
|
||||
.filter { it.role == MessageRole.TOOL && it.toolCallId.isNotBlank() }
|
||||
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
|
||||
.forEach { msg ->
|
||||
liveToolByCallId.putIfAbsent(msg.toolCallId, msg)
|
||||
}
|
||||
|
||||
val mapped = mutableListOf<ChatMessage>()
|
||||
// The gateway stores a reasoning-model's thinking as its OWN assistant
|
||||
// row (content = "", reasoning = trace) directly before the answer row.
|
||||
// Rendering that as a standalone empty assistant bubble is the
|
||||
// "reasoning box in a separate bubble" artifact — fold it into the
|
||||
// next assistant message with content instead. Issue #771.
|
||||
var pendingReasoning: String? = null
|
||||
|
||||
messages.forEachIndexed { index, msg ->
|
||||
val role =
|
||||
when (msg.role?.lowercase()) {
|
||||
"user" -> MessageRole.USER
|
||||
"system" -> MessageRole.SYSTEM
|
||||
"tool" -> MessageRole.TOOL
|
||||
else -> MessageRole.ASSISTANT
|
||||
}
|
||||
val globalIndex = offset + index
|
||||
// Issue #859: under newest-anchored paging use the server's
|
||||
// AUTOINCREMENT row id as the stable key — from-end positions shift
|
||||
// as the transcript grows and would collide across hydrations
|
||||
// (distinctBy would silently drop the newest copy). Legacy paging
|
||||
// keeps the absolute-position key its count-based sync math needs.
|
||||
val restId =
|
||||
if (latestPaging) {
|
||||
msg.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-$globalIndex"
|
||||
} else {
|
||||
"rest-$sessionId-$globalIndex"
|
||||
}
|
||||
val timestamp =
|
||||
msg.timestampText
|
||||
?.toDoubleOrNull()
|
||||
?.times(1000)
|
||||
?.toLong()
|
||||
?: System.currentTimeMillis()
|
||||
|
||||
val rawContent = msg.contentText
|
||||
val rowReasoning =
|
||||
if (msg.reasoningText.isNotBlank()) {
|
||||
msg.reasoningText
|
||||
} else {
|
||||
existingReasoningMap[rawContent]?.reasoningText.orEmpty()
|
||||
}
|
||||
|
||||
// Empty assistant row — two cases stored by the gateway:
|
||||
// 1. Reasoning-only: thinking-model split storage (content = "",
|
||||
// reasoning = trace). Stash the trace and fold it into the
|
||||
// next assistant message that has content (issue #771).
|
||||
// 2. Tool-call placeholder: non-reasoning models emit content = ""
|
||||
// with tool_calls metadata and no reasoning. These carry no
|
||||
// user-visible text and must not render as empty bubbles
|
||||
// (issue #956).
|
||||
if (role == MessageRole.ASSISTANT && rawContent.isBlank()) {
|
||||
if (rowReasoning.isNotBlank()) {
|
||||
pendingReasoning = rowReasoning
|
||||
}
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
var finalContent = rawContent
|
||||
var attachments: List<Attachment>? = null
|
||||
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||
val items = HostMediaExtractor.extract(rawContent)
|
||||
if (items.isNotEmpty()) {
|
||||
val baseUrl = AuthManager.getBaseUrl()
|
||||
val token = AuthManager.getToken().orEmpty()
|
||||
finalContent = HostMediaExtractor.strip(rawContent)
|
||||
attachments =
|
||||
items
|
||||
.mapNotNull { item ->
|
||||
val url =
|
||||
GatewayFileClient.buildMediaUrl(
|
||||
baseUrl,
|
||||
token,
|
||||
item.path,
|
||||
) ?: return@mapNotNull null
|
||||
Attachment(
|
||||
uri = url,
|
||||
name = mediaNameFromPath(item.path),
|
||||
mimeType = mediaMimeForPath(item.path),
|
||||
size = 0,
|
||||
gatewayUrl = url,
|
||||
source = AttachmentSource.GATEWAY,
|
||||
)
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
val finalReasoning =
|
||||
if (rowReasoning.isNotBlank()) {
|
||||
rowReasoning
|
||||
} else if (role == MessageRole.ASSISTANT && pendingReasoning != null) {
|
||||
pendingReasoning.also { pendingReasoning = null }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// Tool rows in the REST transcript carry no tool name. When the
|
||||
// result payload matches a live WS tool message, reuse it whole —
|
||||
// keeps the real name, the rich WS payload, AND the same id so
|
||||
// Room upserts instead of accumulating a duplicate `rest-` row.
|
||||
if (role == MessageRole.TOOL) {
|
||||
// Prefer the gateway call id (1:1, works for every tool
|
||||
// shape), then fall back to result-content matching.
|
||||
liveToolByCallId[msg.toolCallId]?.let { live ->
|
||||
mapped.add(live)
|
||||
return@forEachIndexed
|
||||
}
|
||||
canonicalToolResultKey(rawContent)?.let { key ->
|
||||
liveToolByResult[key]?.let { live ->
|
||||
mapped.add(live)
|
||||
return@forEachIndexed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mapped.add(
|
||||
ChatMessage(
|
||||
id = restId,
|
||||
role = role,
|
||||
content = finalContent,
|
||||
reasoningText = finalReasoning,
|
||||
toolCallId = msg.toolCallId,
|
||||
attachments = attachments,
|
||||
timestamp = timestamp,
|
||||
isStreaming = false,
|
||||
displayKind = msg.display_kind,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// A reasoning-only row with no following answer (interrupted turn):
|
||||
// don't drop the trace — attach it to the last assistant message.
|
||||
if (pendingReasoning != null) {
|
||||
val lastAssistantIdx = mapped.indexOfLast { it.role == MessageRole.ASSISTANT }
|
||||
if (lastAssistantIdx >= 0) {
|
||||
val target = mapped[lastAssistantIdx]
|
||||
if (target.reasoningText.isBlank()) {
|
||||
mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapped
|
||||
}
|
||||
@@ -11,17 +11,12 @@ import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.local.SlashUsageStore
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.GatewayFile
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileResult
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
@@ -524,6 +519,14 @@ class ChatViewModel(
|
||||
get() = searchDelegate.searchState
|
||||
private val attachmentsDelegate = ChatAttachmentsDelegate(uiState = _uiState)
|
||||
|
||||
private val mediaDelegate =
|
||||
ChatMediaDelegate(
|
||||
uiState = _uiState,
|
||||
getApplication = { getApplication() },
|
||||
scope = viewModelScope,
|
||||
ioDispatcher = ioDispatcher,
|
||||
)
|
||||
|
||||
/**
|
||||
* Model options cached from GET /api/model/options so the in-session model
|
||||
* picker (issue #589) opens instantly when the user types /model or taps the
|
||||
@@ -797,7 +800,7 @@ class ChatViewModel(
|
||||
// via the gateway /api/files/download endpoint. Works on a
|
||||
// remote phone too.
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
attachHostMedia(effect.sessionId, effect.messageId)
|
||||
mediaDelegate.attachHostMedia(effect.sessionId, effect.messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1435,6 +1438,19 @@ class ChatViewModel(
|
||||
|
||||
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
|
||||
|
||||
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
|
||||
|
||||
fun saveAttachment(
|
||||
attachment: Attachment,
|
||||
destination: android.net.Uri,
|
||||
) = mediaDelegate.saveAttachment(attachment, destination)
|
||||
|
||||
fun clearOpenError() {
|
||||
_uiState.update { it.copy(openError = null) }
|
||||
}
|
||||
|
||||
fun gatewayPathFor(attachment: Attachment): String = mediaDelegate.gatewayPathFor(attachment)
|
||||
|
||||
fun clearAttachments() = attachmentsDelegate.clearAttachments()
|
||||
|
||||
private fun handleSlashCommand(command: String) {
|
||||
@@ -2126,7 +2142,14 @@ class ChatViewModel(
|
||||
// REST 200 — the gateway has the row for this session.
|
||||
sessionHasServerPresence = true
|
||||
val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset
|
||||
val chatMessages = mapServerMessages(sessionId, result.data.messages.orEmpty(), serverOffset)
|
||||
val chatMessages =
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
result.data.messages.orEmpty(),
|
||||
serverOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
)
|
||||
loadedMessageOffset = serverOffset
|
||||
withContext(ioDispatcher) {
|
||||
repo.persistMessages(chatMessages, sessionId)
|
||||
@@ -2483,7 +2506,14 @@ class ChatViewModel(
|
||||
is NetworkResult.Success -> {
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||
val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: newOffset
|
||||
val older = mapServerMessages(sessionId, result.data.messages.orEmpty(), returnedOffset)
|
||||
val older =
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
result.data.messages.orEmpty(),
|
||||
returnedOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
)
|
||||
loadedMessageOffset = returnedOffset
|
||||
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
|
||||
_uiState.update { current ->
|
||||
@@ -2560,7 +2590,14 @@ class ChatViewModel(
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||
val incoming = mapServerMessages(sessionId, result.data.messages.orEmpty(), nextOffset)
|
||||
val incoming =
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
result.data.messages.orEmpty(),
|
||||
nextOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
)
|
||||
if (incoming.isEmpty()) return@launch
|
||||
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
|
||||
_uiState.update { current ->
|
||||
@@ -2846,456 +2883,6 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapServerMessages(
|
||||
sessionId: String,
|
||||
messages: List<SessionMessage>,
|
||||
offset: Int,
|
||||
): List<ChatMessage> {
|
||||
val existingReasoningMap =
|
||||
_uiState.value.messages
|
||||
.filter { it.reasoningText.isNotBlank() }
|
||||
.associateBy { it.content }
|
||||
|
||||
// Tool rows in the REST transcript carry NO tool name — the live WS
|
||||
// stream was the only source of `toolName`. Match each REST tool row
|
||||
// to its WS counterpart by RESULT CONTENT (not position — pagination
|
||||
// and mixed cache state make positional mapping misalign, leaving
|
||||
// the newest call with a null name → generic "tool" bubble). When a
|
||||
// match is found the live message is reused wholesale (same id +
|
||||
// toolName + rich payload), so persistence upserts the same row
|
||||
// instead of accumulating a second `rest-` copy in Room. Issue #771.
|
||||
val liveToolByResult = linkedMapOf<String, ChatMessage>()
|
||||
_uiState.value.messages
|
||||
.filter { it.role == MessageRole.TOOL }
|
||||
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
|
||||
.forEach { msg ->
|
||||
canonicalToolResultKey(msg.content)?.let { key ->
|
||||
liveToolByResult.putIfAbsent(key, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #842: REST transcript rows carry the gateway's `tool_call_id`
|
||||
// — prefer matching live bubbles by that 1:1 identity. It works for
|
||||
// EVERY tool shape, including MCP/web rows whose REST copy is raw
|
||||
// `<untrusted_tool_result>` text with no JSON key to canonicalize.
|
||||
val liveToolByCallId = linkedMapOf<String, ChatMessage>()
|
||||
_uiState.value.messages
|
||||
.filter { it.role == MessageRole.TOOL && it.toolCallId.isNotBlank() }
|
||||
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
|
||||
.forEach { msg ->
|
||||
liveToolByCallId.putIfAbsent(msg.toolCallId, msg)
|
||||
}
|
||||
|
||||
val mapped = mutableListOf<ChatMessage>()
|
||||
// The gateway stores a reasoning-model's thinking as its OWN assistant
|
||||
// row (content = "", reasoning = trace) directly before the answer row.
|
||||
// Rendering that as a standalone empty assistant bubble is the
|
||||
// "reasoning box in a separate bubble" artifact — fold it into the
|
||||
// next assistant message with content instead. Issue #771.
|
||||
var pendingReasoning: String? = null
|
||||
|
||||
messages.forEachIndexed { index, msg ->
|
||||
val role =
|
||||
when (msg.role?.lowercase()) {
|
||||
"user" -> MessageRole.USER
|
||||
"system" -> MessageRole.SYSTEM
|
||||
"tool" -> MessageRole.TOOL
|
||||
else -> MessageRole.ASSISTANT
|
||||
}
|
||||
val globalIndex = offset + index
|
||||
// Issue #859: under newest-anchored paging use the server's
|
||||
// AUTOINCREMENT row id as the stable key — from-end positions shift
|
||||
// as the transcript grows and would collide across hydrations
|
||||
// (distinctBy would silently drop the newest copy). Legacy paging
|
||||
// keeps the absolute-position key its count-based sync math needs.
|
||||
val restId =
|
||||
if (latestPaging) {
|
||||
msg.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-$globalIndex"
|
||||
} else {
|
||||
"rest-$sessionId-$globalIndex"
|
||||
}
|
||||
val timestamp =
|
||||
msg.timestampText
|
||||
?.toDoubleOrNull()
|
||||
?.times(1000)
|
||||
?.toLong()
|
||||
?: System.currentTimeMillis()
|
||||
|
||||
val rawContent = msg.contentText
|
||||
val rowReasoning =
|
||||
if (msg.reasoningText.isNotBlank()) {
|
||||
msg.reasoningText
|
||||
} else {
|
||||
existingReasoningMap[rawContent]?.reasoningText.orEmpty()
|
||||
}
|
||||
|
||||
// Empty assistant row — two cases stored by the gateway:
|
||||
// 1. Reasoning-only: thinking-model split storage (content = "",
|
||||
// reasoning = trace). Stash the trace and fold it into the
|
||||
// next assistant message that has content (issue #771).
|
||||
// 2. Tool-call placeholder: non-reasoning models emit content = ""
|
||||
// with tool_calls metadata and no reasoning. These carry no
|
||||
// user-visible text and must not render as empty bubbles
|
||||
// (issue #956).
|
||||
if (role == MessageRole.ASSISTANT && rawContent.isBlank()) {
|
||||
if (rowReasoning.isNotBlank()) {
|
||||
pendingReasoning = rowReasoning
|
||||
}
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
var finalContent = rawContent
|
||||
var attachments: List<Attachment>? = null
|
||||
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||
val items = HostMediaExtractor.extract(rawContent)
|
||||
if (items.isNotEmpty()) {
|
||||
val baseUrl = AuthManager.getBaseUrl()
|
||||
val token = AuthManager.getToken().orEmpty()
|
||||
finalContent = HostMediaExtractor.strip(rawContent)
|
||||
attachments =
|
||||
items
|
||||
.mapNotNull { item ->
|
||||
val url =
|
||||
GatewayFileClient.buildMediaUrl(
|
||||
baseUrl,
|
||||
token,
|
||||
item.path,
|
||||
) ?: return@mapNotNull null
|
||||
Attachment(
|
||||
uri = url,
|
||||
name = mediaNameFromPath(item.path),
|
||||
mimeType = mediaMimeForPath(item.path),
|
||||
size = 0,
|
||||
gatewayUrl = url,
|
||||
source = AttachmentSource.GATEWAY,
|
||||
)
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
val finalReasoning =
|
||||
if (rowReasoning.isNotBlank()) {
|
||||
rowReasoning
|
||||
} else if (role == MessageRole.ASSISTANT && pendingReasoning != null) {
|
||||
pendingReasoning.also { pendingReasoning = null }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// Tool rows in the REST transcript carry no tool name. When the
|
||||
// result payload matches a live WS tool message, reuse it whole —
|
||||
// keeps the real name, the rich WS payload, AND the same id so
|
||||
// Room upserts instead of accumulating a duplicate `rest-` row.
|
||||
if (role == MessageRole.TOOL) {
|
||||
// Prefer the gateway call id (1:1, works for every tool
|
||||
// shape), then fall back to result-content matching.
|
||||
liveToolByCallId[msg.toolCallId]?.let { live ->
|
||||
mapped.add(live)
|
||||
return@forEachIndexed
|
||||
}
|
||||
canonicalToolResultKey(rawContent)?.let { key ->
|
||||
liveToolByResult[key]?.let { live ->
|
||||
mapped.add(live)
|
||||
return@forEachIndexed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mapped.add(
|
||||
ChatMessage(
|
||||
id = restId,
|
||||
role = role,
|
||||
content = finalContent,
|
||||
reasoningText = finalReasoning,
|
||||
toolCallId = msg.toolCallId,
|
||||
attachments = attachments,
|
||||
timestamp = timestamp,
|
||||
isStreaming = false,
|
||||
displayKind = msg.display_kind,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// A reasoning-only row with no following answer (interrupted turn):
|
||||
// don't drop the trace — attach it to the last assistant message.
|
||||
if (pendingReasoning != null) {
|
||||
val lastAssistantIdx = mapped.indexOfLast { it.role == MessageRole.ASSISTANT }
|
||||
if (lastAssistantIdx >= 0) {
|
||||
val target = mapped[lastAssistantIdx]
|
||||
if (target.reasoningText.isBlank()) {
|
||||
mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapped
|
||||
}
|
||||
|
||||
// ── Issue #724: attach host-path MEDIA: files as real attachments ────
|
||||
//
|
||||
// The gateway's WebSocket stream delivers the raw `MEDIA:<path>` directive
|
||||
// the desktop app turns into an authenticated `/api/files/download?...`
|
||||
// URL. We parse every directive, build the download URL via
|
||||
// [GatewayFileClient], classify it (image / audio / video / file) using
|
||||
// [mediaKindForPath], and attach it to the message. Images render inline
|
||||
// (Coil loads the URL); every other type becomes a tappable, fetchable
|
||||
// attachment. The directive text is stripped from the message body. Works
|
||||
// on a remote phone (real HTTP). Mobile-only; backend untouched. Pure
|
||||
// parsing lives in [HostMediaExtractor].
|
||||
|
||||
/**
|
||||
* ViewModel-side handler for [ReducerEffect.AttachHostMedia]: find the local
|
||||
* message by id, convert any `MEDIA:<path>` directives into [Attachment]s
|
||||
* (via the gateway download URL) and strip them from the text. Role,
|
||||
* reasoning, timestamp and existing attachments are preserved; new gateway
|
||||
* attachments are appended. Idempotent — skips if gateway attachments for
|
||||
* the same paths already exist.
|
||||
*/
|
||||
private fun attachHostMedia(
|
||||
sessionId: String,
|
||||
messageId: String,
|
||||
) {
|
||||
val current = _uiState.value.messages.find { it.id == messageId } ?: return
|
||||
val content = current.content
|
||||
val items = HostMediaExtractor.extract(content)
|
||||
if (items.isEmpty()) return
|
||||
|
||||
val baseUrl = AuthManager.getBaseUrl()
|
||||
if (baseUrl.isBlank()) return
|
||||
// NOTE: no token gate here. Gated (basic-auth) dashboards download via
|
||||
// the session cookie, and even loopback hosts can serve files with an
|
||||
// empty query token — requiring a non-blank token dropped every MEDIA
|
||||
// attachment on gate-auth connections where getToken() returns null.
|
||||
|
||||
val existingUrls =
|
||||
current.attachments
|
||||
.orEmpty()
|
||||
.mapNotNull { it.gatewayUrl }
|
||||
.toSet()
|
||||
val newAttachments =
|
||||
items.mapNotNull { item ->
|
||||
val token = AuthManager.getToken().orEmpty()
|
||||
val url = GatewayFileClient.buildMediaUrl(baseUrl, token, item.path) ?: return@mapNotNull null
|
||||
if (url in existingUrls) return@mapNotNull null
|
||||
Attachment(
|
||||
uri = url,
|
||||
name = mediaNameFromPath(item.path),
|
||||
mimeType = mediaMimeForPath(item.path),
|
||||
size = 0,
|
||||
gatewayUrl = url,
|
||||
source = AttachmentSource.GATEWAY,
|
||||
)
|
||||
}
|
||||
if (newAttachments.isEmpty()) return
|
||||
|
||||
val stripped = HostMediaExtractor.strip(content)
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
messages =
|
||||
state.messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(
|
||||
content = stripped,
|
||||
attachments =
|
||||
(msg.attachments.orEmpty() + newAttachments)
|
||||
.distinctBy { it.gatewayUrl ?: it.uri },
|
||||
)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an attachment when its chip/thumbnail is tapped.
|
||||
*
|
||||
* - LOCAL (user-picked) files: open the original `content://` URI
|
||||
* directly via [android.content.Intent.ACTION_VIEW] — the resolver
|
||||
* already grants read access for the picked document. If that fails
|
||||
* (e.g. the permission lapsed), we copy to cache and retry via
|
||||
* FileProvider so the tap is never a silent no-op.
|
||||
* - GATEWAY (agent `MEDIA:`) files: stream the file to cache via
|
||||
* [GatewayFileClient] (chunked — never held in memory), then open with
|
||||
* [android.content.Intent.ACTION_VIEW] through FileProvider — so a
|
||||
* remote phone can view agent-delivered files in-place.
|
||||
*
|
||||
* Failures surface through [ChatUiState.openError] (non-blocking
|
||||
* snackbar); the tap is never swallowed.
|
||||
*/
|
||||
fun openAttachment(attachment: Attachment) {
|
||||
val ctx = getApplication<Application>().applicationContext
|
||||
if (attachment.source == AttachmentSource.LOCAL) {
|
||||
// Best-effort direct open of the picked content URI.
|
||||
runCatching { openWithView(ctx, android.net.Uri.parse(attachment.uri), attachment.mimeType) }
|
||||
.onSuccess { return }
|
||||
.onFailure { /* fall through to cache-copy below */ }
|
||||
}
|
||||
// GATEWAY, or LOCAL direct-open failed → fetch/copy then open.
|
||||
val path = gatewayPathFor(attachment)
|
||||
// Show a loading indicator on the attachment card while the file
|
||||
// streams down (mirrors the save spinner; cleared in all outcomes).
|
||||
_uiState.update { it.copy(openingAttachmentPath = path) }
|
||||
val cacheDir = java.io.File(ctx.cacheDir, "gateway_files")
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
try {
|
||||
when (val result = GatewayFileClient.fetch(path, cacheDir)) {
|
||||
is GatewayFileResult.Success -> {
|
||||
openBytes(ctx, result.file)
|
||||
}
|
||||
|
||||
is GatewayFileResult.NotFound -> {
|
||||
showOpenError("File not found on gateway: ${attachment.name}")
|
||||
}
|
||||
|
||||
is GatewayFileResult.Forbidden -> {
|
||||
showOpenError("Access denied: ${attachment.name}")
|
||||
}
|
||||
|
||||
is GatewayFileResult.TooLarge -> {
|
||||
showOpenError("File too large to open: ${attachment.name}")
|
||||
}
|
||||
|
||||
is GatewayFileResult.Unauthorized -> {
|
||||
showOpenError("Session expired — reconnect to open: ${attachment.name}")
|
||||
}
|
||||
|
||||
is GatewayFileResult.Failure -> {
|
||||
showOpenError("Could not open ${attachment.name}: ${result.throwable.message}")
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_uiState.update {
|
||||
if (it.openingAttachmentPath == path) it.copy(openingAttachmentPath = null) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Save an agent-delivered file through Android's system document picker. */
|
||||
fun saveAttachment(
|
||||
attachment: Attachment,
|
||||
destination: android.net.Uri,
|
||||
) {
|
||||
if (_uiState.value.savingAttachmentPath != null) return
|
||||
val path = gatewayPathFor(attachment)
|
||||
val cacheDir =
|
||||
java.io.File(getApplication<Application>().applicationContext.cacheDir, "gateway_files")
|
||||
_uiState.update { it.copy(savingAttachmentPath = path) }
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
try {
|
||||
when (val result = GatewayFileClient.fetch(path, cacheDir)) {
|
||||
is GatewayFileResult.Success -> {
|
||||
val resolver = getApplication<Application>().contentResolver
|
||||
runCatching {
|
||||
resolver
|
||||
.openOutputStream(destination, "wt")
|
||||
?.use { output ->
|
||||
result.file.cacheFile.inputStream().use { input ->
|
||||
GatewayFileClient.copyChunked(input, output)
|
||||
}
|
||||
}
|
||||
?: error("destination is unavailable")
|
||||
}.onSuccess {
|
||||
showOpenError("Saved ${result.file.name}")
|
||||
}.onFailure {
|
||||
showOpenError("Could not save ${attachment.name}: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
is GatewayFileResult.NotFound -> showOpenError("File not found on gateway: ${attachment.name}")
|
||||
is GatewayFileResult.Forbidden -> showOpenError("Access denied: ${attachment.name}")
|
||||
is GatewayFileResult.TooLarge -> showOpenError("File too large to save: ${attachment.name}")
|
||||
is GatewayFileResult.Unauthorized ->
|
||||
showOpenError(
|
||||
"Session expired — reconnect to save: ${attachment.name}",
|
||||
)
|
||||
is GatewayFileResult.Failure ->
|
||||
showOpenError("Could not save ${attachment.name}: ${result.throwable.message}")
|
||||
}
|
||||
} finally {
|
||||
_uiState.update {
|
||||
if (it.savingAttachmentPath == path) {
|
||||
it.copy(savingAttachmentPath = null)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun gatewayPathFor(attachment: Attachment): String =
|
||||
attachment.gatewayUrl?.let(::gatewayPathFromUrl)
|
||||
?: attachment.uri.removePrefix("gateway:").takeIf { it != attachment.uri }
|
||||
?: attachment.name
|
||||
|
||||
/** Open a gateway file already streamed to cache via FileProvider + ACTION_VIEW. */
|
||||
private fun openBytes(
|
||||
ctx: android.content.Context,
|
||||
file: GatewayFile,
|
||||
) {
|
||||
runCatching {
|
||||
val uri =
|
||||
androidx.core.content.FileProvider.getUriForFile(
|
||||
ctx,
|
||||
"${ctx.packageName}.fileprovider",
|
||||
file.cacheFile,
|
||||
)
|
||||
openWithView(ctx, uri, file.mimeType)
|
||||
}.onFailure { showOpenError("Could not open ${file.name}: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Fire an ACTION_VIEW intent; throws if no activity can handle the type. */
|
||||
private fun openWithView(
|
||||
ctx: android.content.Context,
|
||||
uri: android.net.Uri,
|
||||
mimeType: String,
|
||||
) {
|
||||
val viewIntent =
|
||||
android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mimeType.ifBlank { "*/*" })
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
try {
|
||||
ctx.startActivity(viewIntent)
|
||||
} catch (e: Throwable) {
|
||||
val fallbackIntent =
|
||||
android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "*/*")
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
val chooser =
|
||||
android.content.Intent.createChooser(fallbackIntent, "Open file").apply {
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
ctx.startActivity(chooser)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showOpenError(message: String) {
|
||||
_uiState.update { it.copy(openError = message) }
|
||||
}
|
||||
|
||||
fun clearOpenError() {
|
||||
_uiState.update { it.copy(openError = null) }
|
||||
}
|
||||
|
||||
private fun sameMessages(
|
||||
left: List<ChatMessage>,
|
||||
right: List<ChatMessage>,
|
||||
): Boolean =
|
||||
left.size == right.size &&
|
||||
left.zip(right).all { (a, b) ->
|
||||
a.id == b.id &&
|
||||
a.role == b.role &&
|
||||
a.content == b.content &&
|
||||
a.reasoningText == b.reasoningText
|
||||
}
|
||||
|
||||
// ── UI actions ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user