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.HermesDatabase
|
||||||
import com.m57.hermescontrol.data.local.SlashUsageStore
|
import com.m57.hermescontrol.data.local.SlashUsageStore
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
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.ModelCapabilities
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
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.parseContextBreakdown
|
||||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
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.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
@@ -524,6 +519,14 @@ class ChatViewModel(
|
|||||||
get() = searchDelegate.searchState
|
get() = searchDelegate.searchState
|
||||||
private val attachmentsDelegate = ChatAttachmentsDelegate(uiState = _uiState)
|
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
|
* 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
|
* 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
|
// via the gateway /api/files/download endpoint. Works on a
|
||||||
// remote phone too.
|
// remote phone too.
|
||||||
viewModelScope.launch(ioDispatcher) {
|
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 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()
|
fun clearAttachments() = attachmentsDelegate.clearAttachments()
|
||||||
|
|
||||||
private fun handleSlashCommand(command: String) {
|
private fun handleSlashCommand(command: String) {
|
||||||
@@ -2126,7 +2142,14 @@ class ChatViewModel(
|
|||||||
// REST 200 — the gateway has the row for this session.
|
// REST 200 — the gateway has the row for this session.
|
||||||
sessionHasServerPresence = true
|
sessionHasServerPresence = true
|
||||||
val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset
|
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
|
loadedMessageOffset = serverOffset
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
repo.persistMessages(chatMessages, sessionId)
|
repo.persistMessages(chatMessages, sessionId)
|
||||||
@@ -2483,7 +2506,14 @@ class ChatViewModel(
|
|||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||||
val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: newOffset
|
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
|
loadedMessageOffset = returnedOffset
|
||||||
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
|
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
|
||||||
_uiState.update { current ->
|
_uiState.update { current ->
|
||||||
@@ -2560,7 +2590,14 @@ class ChatViewModel(
|
|||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
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
|
if (incoming.isEmpty()) return@launch
|
||||||
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
|
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
|
||||||
_uiState.update { current ->
|
_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 ───────────────────────────────────────────────────────
|
// ── UI actions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user