diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt new file mode 100644 index 00000000..1d0de41e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt @@ -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, + 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, + right: List, +): 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 + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt new file mode 100644 index 00000000..82eeffd5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -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, + offset: Int, + latestPaging: Boolean, + liveMessages: List, +): List { + 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() + 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 + // `` text with no JSON key to canonicalize. + val liveToolByCallId = linkedMapOf() + 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() + // 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? = 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 +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index b0ea9aa1..0bdd0971 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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, - offset: Int, - ): List { - 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() - _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 - // `` text with no JSON key to canonicalize. - val liveToolByCallId = linkedMapOf() - _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() - // 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? = 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:` 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:` 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().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().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().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, - right: List, - ): 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 ─────────────────────────────────────────────────────── /**