refactor(chat): pure server message mapper (#1363)
This commit is contained in:
5 files changed
+179
-32
No files matched your search
@@ -1,20 +1,24 @@
|
||||
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
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTarget
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
|
||||
*
|
||||
* Reads the supplied transcript snapshot and recovers notification identity through
|
||||
* ReplyNotificationTracker's synchronized lookup. Safe on the history dispatcher;
|
||||
* it never mutates ViewModel state or dismisses a notification.
|
||||
* Pure: every input is plain data. Callers resolve the active reply-notification
|
||||
* target and the gateway media URL builder, so this never touches Android,
|
||||
* AuthManager, or the notification tracker (#1337).
|
||||
*
|
||||
* @param activeReplyTarget the reply notification currently posted, if any; its
|
||||
* durable server row id reserves that row's completion identity.
|
||||
* @param mediaUrl builds an authenticated gateway URL for a `MEDIA:` path, or null
|
||||
* when no gateway is configured.
|
||||
* @param nowMs fallback timestamp for rows without one.
|
||||
*/
|
||||
internal fun mapServerMessages(
|
||||
sessionId: String,
|
||||
@@ -24,7 +28,9 @@ internal fun mapServerMessages(
|
||||
liveMessages: List<ChatMessage>,
|
||||
isPagingOlder: Boolean = false,
|
||||
stableRowIds: Boolean = latestPaging,
|
||||
context: android.content.Context? = null,
|
||||
activeReplyTarget: ReplyNotificationTarget? = null,
|
||||
mediaUrl: (path: String) -> String? = { null },
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): List<ChatMessage> {
|
||||
val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id }
|
||||
val liveByExactId =
|
||||
@@ -55,7 +61,7 @@ internal fun mapServerMessages(
|
||||
if (!isPagingOlder) {
|
||||
// #129: reserve durable identity before matching any repeated live prose.
|
||||
// REST-only recovery remains fail-closed when the target row is absent.
|
||||
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)?.takeIf { it.sessionId == sessionId }
|
||||
val activeTarget = activeReplyTarget?.takeIf { it.sessionId == sessionId }
|
||||
val durableTarget = activeTarget?.takeIf { it.serverMessageId != null }
|
||||
if (durableTarget != null) {
|
||||
val exactIndex =
|
||||
@@ -179,7 +185,7 @@ internal fun mapServerMessages(
|
||||
?.times(1000)
|
||||
?.toLong()
|
||||
?: existingById[restId]?.timestamp
|
||||
?: System.currentTimeMillis()
|
||||
?: nowMs
|
||||
|
||||
val rawContent =
|
||||
if (role == MessageRole.TOOL && msg.display_metadata != null) {
|
||||
@@ -229,18 +235,11 @@ internal fun mapServerMessages(
|
||||
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
|
||||
val url = mediaUrl(item.path) ?: return@mapNotNull null
|
||||
Attachment(
|
||||
uri = url,
|
||||
name = mediaNameFromPath(item.path),
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.m57.hermescontrol.data.model.parseContextBreakdown
|
||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
||||
import com.m57.hermescontrol.data.model.reasoningSupport
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
@@ -38,6 +39,7 @@ import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.toAny
|
||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
import com.m57.hermescontrol.notification.captureTurnBoundary
|
||||
import com.m57.hermescontrol.notification.correlationScopeId
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState
|
||||
@@ -2249,6 +2251,10 @@ class ChatViewModel(
|
||||
|
||||
// ── Send message ─────────────────────────────────────────────────────
|
||||
|
||||
/** Authenticated gateway URL for a host `MEDIA:` path; kept out of the pure mapper (#1337). */
|
||||
private fun gatewayMediaUrl(path: String): String? =
|
||||
GatewayFileClient.buildMediaUrl(AuthManager.getBaseUrl(), AuthManager.getToken().orEmpty(), path)
|
||||
|
||||
private fun sendScope(): String =
|
||||
listOf(
|
||||
AuthManager.getBaseUrl(),
|
||||
@@ -4242,7 +4248,8 @@ class ChatViewModel(
|
||||
offset = 0,
|
||||
latestPaging = latestPaging,
|
||||
liveMessages = emptyList(),
|
||||
context = getApplication(),
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()),
|
||||
mediaUrl = ::gatewayMediaUrl,
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(messages = replacementMessages) }
|
||||
@@ -4736,7 +4743,8 @@ class ChatViewModel(
|
||||
liveMessages = _uiState.value.messages,
|
||||
isPagingOlder = true,
|
||||
stableRowIds = true,
|
||||
context = getApplication(),
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()),
|
||||
mediaUrl = ::gatewayMediaUrl,
|
||||
)
|
||||
}
|
||||
if (!valid()) return@launch
|
||||
@@ -5046,7 +5054,8 @@ class ChatViewModel(
|
||||
serverOffset,
|
||||
useLatest,
|
||||
current,
|
||||
context = getApplication(),
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()),
|
||||
mediaUrl = ::gatewayMediaUrl,
|
||||
)
|
||||
} ?: return@launch
|
||||
persistHistoryPage(page, sessionId)
|
||||
@@ -5503,7 +5512,8 @@ class ChatViewModel(
|
||||
useLatest,
|
||||
current,
|
||||
isPagingOlder = true,
|
||||
context = getApplication(),
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()),
|
||||
mediaUrl = ::gatewayMediaUrl,
|
||||
)
|
||||
} ?: return@launch
|
||||
persistHistoryPage(page, sessionId)
|
||||
@@ -5584,7 +5594,8 @@ class ChatViewModel(
|
||||
current,
|
||||
// Sync fetches recent replies, so confirm live completion identities.
|
||||
isPagingOlder = false,
|
||||
context = getApplication(),
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()),
|
||||
mediaUrl = ::gatewayMediaUrl,
|
||||
)
|
||||
} ?: return@launch
|
||||
persistHistoryPage(page, sessionId)
|
||||
|
||||
Reference in new issue
Block a user