feat(chat): show session-scoped reply failure details (#1255)
This commit is contained in:
1 parent
94ec95a33c
commit
0d4c0b40ad
13 files changed
+1251
-28
No files matched your search
@@ -17,6 +17,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.ui.chat.replyFailureFromPayload
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -27,6 +28,46 @@ import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal data class MessageCompleteNotificationPlan(
|
||||
val text: String,
|
||||
val sessionId: String?,
|
||||
val isReplyMessage: Boolean,
|
||||
val completionId: String?,
|
||||
val correlationText: String?,
|
||||
val allowInlineReply: Boolean,
|
||||
)
|
||||
|
||||
internal fun messageCompleteNotificationPlan(
|
||||
event: WsEvent.MessageComplete,
|
||||
targetSessionId: String?,
|
||||
newMessageText: String,
|
||||
failureText: String,
|
||||
): MessageCompleteNotificationPlan {
|
||||
if (replyFailureFromPayload(event.rawPayload, fallback = "") != null) {
|
||||
return MessageCompleteNotificationPlan(
|
||||
text = failureText,
|
||||
sessionId = targetSessionId,
|
||||
isReplyMessage = false,
|
||||
completionId = null,
|
||||
correlationText = null,
|
||||
allowInlineReply = false,
|
||||
)
|
||||
}
|
||||
|
||||
return MessageCompleteNotificationPlan(
|
||||
text =
|
||||
event.text
|
||||
.take(100)
|
||||
.replace("\n", " ")
|
||||
.ifBlank { newMessageText },
|
||||
sessionId = targetSessionId,
|
||||
isReplyMessage = true,
|
||||
completionId = event.completionId,
|
||||
correlationText = event.text,
|
||||
allowInlineReply = true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground service that keeps the WebSocket connection alive while the app
|
||||
* is backgrounded and posts a notification when a new assistant reply
|
||||
@@ -111,19 +152,22 @@ class ChatNotificationService : Service() {
|
||||
if (!isAppInForeground.get()) {
|
||||
when (event) {
|
||||
is WsEvent.MessageComplete -> {
|
||||
val preview =
|
||||
event.text
|
||||
.take(100)
|
||||
.replace("\n", " ")
|
||||
.ifBlank { getString(R.string.notif_new_message) }
|
||||
val targetSessionId =
|
||||
event.storedSessionId
|
||||
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
|
||||
val plan =
|
||||
messageCompleteNotificationPlan(
|
||||
event = event,
|
||||
targetSessionId = targetSessionId,
|
||||
newMessageText = getString(R.string.notif_new_message),
|
||||
failureText = getString(R.string.chat_reply_failed_title),
|
||||
)
|
||||
showReplyNotification(
|
||||
text = preview,
|
||||
sessionId = targetSessionId,
|
||||
isReplyMessage = true,
|
||||
completionId = event.completionId,
|
||||
text = plan.text,
|
||||
sessionId = plan.sessionId,
|
||||
isReplyMessage = plan.isReplyMessage,
|
||||
completionId = plan.completionId,
|
||||
allowInlineReply = plan.allowInlineReply,
|
||||
// The durable REST row for this turn, when the
|
||||
// boundary armed before the prompt was submitted
|
||||
// still lets us name it unambiguously. Null is a
|
||||
@@ -132,7 +176,9 @@ class ChatNotificationService : Service() {
|
||||
// is strictly better than dismissing the wrong
|
||||
// duplicate reply.
|
||||
serverMessageId =
|
||||
coalesceTurnRow(targetSessionId, event.text),
|
||||
plan.correlationText?.let {
|
||||
coalesceTurnRow(plan.sessionId, it)
|
||||
},
|
||||
)
|
||||
// The wait is over — retire the foreground
|
||||
// service. The reply notification above
|
||||
@@ -216,6 +262,7 @@ class ChatNotificationService : Service() {
|
||||
isReplyMessage: Boolean = false,
|
||||
completionId: String? = null,
|
||||
serverMessageId: Int? = null,
|
||||
allowInlineReply: Boolean = true,
|
||||
) {
|
||||
val builder =
|
||||
NotificationCompat
|
||||
@@ -262,7 +309,7 @@ class ChatNotificationService : Service() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!sessionId.isNullOrBlank()) {
|
||||
if (allowInlineReply && !sessionId.isNullOrBlank()) {
|
||||
val replyLabel = getString(R.string.notif_reply_placeholder)
|
||||
val remoteInput =
|
||||
RemoteInput
|
||||
|
||||
@@ -64,9 +64,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -76,8 +78,10 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||
import com.m57.hermescontrol.HistoryScreen
|
||||
import com.m57.hermescontrol.LogsScreen
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
@@ -102,6 +106,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
||||
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||
import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
|
||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
||||
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
||||
@@ -784,6 +789,39 @@ fun ChatScreen(
|
||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||
openingAttachmentPath = state.openingAttachmentPath,
|
||||
onImageClick = { viewingImage = it },
|
||||
replyErrorContent =
|
||||
state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure ->
|
||||
{
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val copiedMessage = stringResource(R.string.chat_reply_failed_copied)
|
||||
val shareTitle = stringResource(R.string.chat_reply_failed_share)
|
||||
val shareUnavailable = stringResource(R.string.chat_reply_failed_share_unavailable)
|
||||
ReplyErrorCard(
|
||||
failure = failure,
|
||||
onDismiss = { viewModel.dismissReplyFailure(failure.id) },
|
||||
onOpenLogs = { NavigationController.navigateTo(LogsScreen) },
|
||||
onCopy = { details ->
|
||||
clipboard.setText(AnnotatedString(details))
|
||||
scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) }
|
||||
},
|
||||
onShare = { details ->
|
||||
val report = "Hermes Mobile ${BuildConfig.VERSION_NAME}\n\n$details"
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, report)
|
||||
}
|
||||
try {
|
||||
launchExternalActivity {
|
||||
context.startActivity(Intent.createChooser(intent, shareTitle))
|
||||
}
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
scrollScope.launch { snackbarHostState.showSnackbar(shareUnavailable) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Loading overlay
|
||||
|
||||
@@ -137,6 +137,10 @@ data class ChatUiState(
|
||||
/** Standalone streaming message — rendered after the main list. */
|
||||
val streamingMessage: ChatMessage? = null,
|
||||
val errorMessage: String? = null,
|
||||
/** Persistent until dismissed/new turn; distinct from one-shot RPC/network snackbars. */
|
||||
val replyFailure: ReplyFailure? = null,
|
||||
/** Identity of the single non-durable failed assistant projection for this session lifecycle. */
|
||||
val replyFailureProjection: ReplyFailureProjection? = null,
|
||||
// Background job completion toast (issue #527) — non-blocking snackbar
|
||||
val backgroundCompleteMessage: String? = null,
|
||||
// Attachment feedback — surfaced as a non-blocking snackbar (issue #724)
|
||||
@@ -842,6 +846,23 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
private fun handleWsEvent(event: WsEvent) {
|
||||
// Filter explicitly scoped terminal/start events before buffer resets and reduction.
|
||||
// Legacy unscoped failures are ambiguous across sessions, so fail closed: gateways
|
||||
// must include session_id for failed replies to be surfaced by this client.
|
||||
val turnSessionId =
|
||||
when (event) {
|
||||
is WsEvent.MessageStart -> event.sessionId
|
||||
is WsEvent.MessageComplete -> event.sessionId
|
||||
is WsEvent.MessageDone -> event.sessionId
|
||||
else -> null
|
||||
}
|
||||
if (turnSessionId != null && !isCurrentSession(turnSessionId)) return
|
||||
if (event is WsEvent.MessageComplete && event.rawPayload?.get("status") == "error" &&
|
||||
event.sessionId == null
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// RpcError is reduced before ViewModel request handling. Drop stale
|
||||
// session errors here so the shared reducer cannot clear loading or
|
||||
// surface an error for a newly selected session.
|
||||
@@ -1238,10 +1259,13 @@ class ChatViewModel(
|
||||
|
||||
// ── Message streaming ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Checks if an incoming WS event belongs to the currently active
|
||||
* session. Returns true if the event should be processed.
|
||||
*/
|
||||
fun dismissReplyFailure(id: String) {
|
||||
_uiState.update { state ->
|
||||
if (state.replyFailure?.id == id) state.copy(replyFailure = null) else state
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if an incoming WS event belongs to the currently active session. */
|
||||
private fun isCurrentSession(eventSessionId: String?): Boolean {
|
||||
// If the event has no session ID, process it (legacy compatibility)
|
||||
if (eventSessionId == null) return true
|
||||
@@ -1510,20 +1534,23 @@ class ChatViewModel(
|
||||
|
||||
WsMethods.SESSION_RESUME -> {
|
||||
val resultMap = result as? Map<String, Any?>
|
||||
// A resume response may carry retained terminal state. Correlate the
|
||||
// exact request and stored session before binding the runtime id or
|
||||
// projecting any failure; a late response must not touch the new chat.
|
||||
val resumeRequest = request ?: return
|
||||
val sessionId = resumeRequest.sessionId ?: return
|
||||
if (sessionId != _uiState.value.currentSessionId) return
|
||||
val resumedStoredId = (resultMap?.get("resumed") as? String)?.takeIf { it.isNotBlank() }
|
||||
if (resumedStoredId != null && resumedStoredId != sessionId) return
|
||||
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
|
||||
if (runtimeId == null) {
|
||||
val sessionId = request?.sessionId ?: _uiState.value.currentSessionId ?: return
|
||||
handleResumeFailure(sessionId, sessionGeneration, "Invalid session resume response")
|
||||
handleResumeFailure(sessionId, resumeRequest.generation, "Invalid session resume response")
|
||||
return
|
||||
}
|
||||
runtimeSessionId = runtimeId
|
||||
connectionOperationDelegate.bindSession(runtimeId)
|
||||
// Resume succeeded — the gateway confirmed the DB row.
|
||||
sessionHasServerPresence = true
|
||||
val sessionId =
|
||||
request?.sessionId
|
||||
?: (resultMap["resumed"] as? String)
|
||||
?: _uiState.value.currentSessionId
|
||||
|
||||
// Parse session info from backend — model, provider, reasoning_effort
|
||||
val infoMap = resultMap["info"] as? Map<String, Any?>
|
||||
@@ -1592,7 +1619,8 @@ class ChatViewModel(
|
||||
ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
|
||||
addSystemMessage("Session resumed")
|
||||
fetchContextUsage()
|
||||
val generation = request?.generation ?: sessionGeneration
|
||||
projectRetainedReplyFailure(resultMap["inflight"] as? Map<String, Any?>, runtimeId)
|
||||
val generation = resumeRequest.generation
|
||||
resumedGeneration = generation
|
||||
finishResumeWhenHydrated(generation)
|
||||
subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId)
|
||||
@@ -1663,6 +1691,25 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a gateway-retained failed turn through the same reducer path as a
|
||||
* live terminal `message.complete(status=error)`. The assistant field is
|
||||
* the partial display projection; user/transcript fields are never exported
|
||||
* as diagnostics, and reducer effects intentionally do not persist it.
|
||||
*/
|
||||
private fun projectRetainedReplyFailure(
|
||||
inflight: Map<String, Any?>?,
|
||||
runtimeId: String,
|
||||
) {
|
||||
if (inflight?.get("status") != "error") return
|
||||
val result =
|
||||
ChatWsEventReducer.reduceRetainedReplyFailure(_uiState.value, inflight, runtimeId)
|
||||
_uiState.value = result.state
|
||||
_streamingState.value = result.streamingState
|
||||
dispatchReducerEffects(result.effects)
|
||||
streamingController.resetStreaming()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun handleDispatchResult(result: Any?) {
|
||||
val map = result as? Map<*, *> ?: return
|
||||
@@ -3546,6 +3593,8 @@ class ChatViewModel(
|
||||
hasOlderMessages = false,
|
||||
streamingMessage = null,
|
||||
errorMessage = null,
|
||||
replyFailure = null,
|
||||
replyFailureProjection = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
|
||||
@@ -69,6 +69,40 @@ private fun stripSealedOrphanPrefix(
|
||||
* Android or ViewModel classes. Easy to unit test.
|
||||
*/
|
||||
object ChatWsEventReducer {
|
||||
/**
|
||||
* Projects retained terminal state without consulting any live streaming buffers.
|
||||
* Only `inflight.assistant` may become assistant prose; stale UI text, reasoning,
|
||||
* and message ids are deliberately excluded.
|
||||
*/
|
||||
fun reduceRetainedReplyFailure(
|
||||
state: ChatUiState,
|
||||
inflight: Map<String, Any?>,
|
||||
currentSessionId: String,
|
||||
): ReducerResult {
|
||||
if (inflight["status"] != "error") return ReducerResult(state, StreamingState())
|
||||
val assistant = (inflight["assistant"] as? String).orEmpty()
|
||||
val payload = inflight.toMutableMap().apply { put("partial", assistant.isNotBlank()) }
|
||||
val cleanState =
|
||||
state.copy(
|
||||
isAgentTyping = false,
|
||||
isThinking = false,
|
||||
thinkingText = "",
|
||||
streamingMessage = null,
|
||||
)
|
||||
return reduce(
|
||||
cleanState,
|
||||
StreamingState(),
|
||||
WsEvent.MessageComplete(
|
||||
text = assistant,
|
||||
sessionId = currentSessionId,
|
||||
// Retained inflight has no completion identity; suppress the event's random default.
|
||||
completionId = "",
|
||||
rawPayload = payload,
|
||||
),
|
||||
currentSessionId,
|
||||
)
|
||||
}
|
||||
|
||||
fun reduce(
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
@@ -239,6 +273,18 @@ object ChatWsEventReducer {
|
||||
streamingState: StreamingState,
|
||||
event: WsEvent.MessageStart,
|
||||
): ReducerResult {
|
||||
val previousFailureMessageId = state.replyFailureProjection?.messageId
|
||||
val turnState =
|
||||
state.copy(
|
||||
messages =
|
||||
if (previousFailureMessageId == null) {
|
||||
state.messages
|
||||
} else {
|
||||
state.messages.filterNot { it.id == previousFailureMessageId }
|
||||
},
|
||||
replyFailure = null,
|
||||
replyFailureProjection = null,
|
||||
)
|
||||
val msg =
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
@@ -252,10 +298,10 @@ object ChatWsEventReducer {
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
|
||||
val cleanedSubagents =
|
||||
if (state.subagentIndicators.all { it.isComplete || it.isFailed }) {
|
||||
if (turnState.subagentIndicators.all { it.isComplete || it.isFailed }) {
|
||||
emptyList()
|
||||
} else {
|
||||
state.subagentIndicators
|
||||
turnState.subagentIndicators
|
||||
}
|
||||
|
||||
// Build new state: finalize any orphan streaming message, then set the new one
|
||||
@@ -270,13 +316,13 @@ object ChatWsEventReducer {
|
||||
tps = null,
|
||||
)
|
||||
orphan = finalized
|
||||
state.copy(
|
||||
messages = state.messages.upsertById(finalized),
|
||||
turnState.copy(
|
||||
messages = turnState.messages.upsertById(finalized),
|
||||
isAgentTyping = true,
|
||||
subagentIndicators = cleanedSubagents,
|
||||
)
|
||||
} else {
|
||||
state.copy(
|
||||
turnState.copy(
|
||||
isAgentTyping = true,
|
||||
subagentIndicators = cleanedSubagents,
|
||||
)
|
||||
@@ -406,6 +452,8 @@ object ChatWsEventReducer {
|
||||
streamingState: StreamingState,
|
||||
event: WsEvent.MessageComplete,
|
||||
): ReducerResult {
|
||||
val failure = replyFailureFromPayload(event.rawPayload, event.text)
|
||||
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
|
||||
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
|
||||
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
|
||||
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
|
||||
@@ -520,6 +568,86 @@ object ChatWsEventReducer {
|
||||
)
|
||||
}
|
||||
|
||||
// Keep terminal diagnostics out of normal assistant messages and persistence.
|
||||
private fun onReplyFailure(
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
event: WsEvent.MessageComplete,
|
||||
failure: ReplyFailure,
|
||||
): ReducerResult {
|
||||
val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state
|
||||
val streaming = streamingState.streamingMessage
|
||||
val text =
|
||||
if (event.rawPayload?.get("partial") == true) {
|
||||
event.text
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { stripSealedOrphanPrefix(it, state.messages, streamingState.sealedOrphanIds) }
|
||||
?: streaming?.content.orEmpty()
|
||||
} else {
|
||||
streaming?.content.orEmpty()
|
||||
}
|
||||
val reasoning = streamingState.reasoningText.ifBlank { streaming?.reasoningText.orEmpty() }
|
||||
val existingProjection = state.replyFailureProjection
|
||||
val completionId = event.completionId?.takeIf { it.isNotBlank() } ?: existingProjection?.completionId
|
||||
val sessionIdentity = state.currentSessionId ?: event.sessionId.orEmpty()
|
||||
val failureId =
|
||||
existingProjection?.failureId
|
||||
?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-$sessionIdentity-$it" }
|
||||
?: failure.id
|
||||
val projectionMessageId =
|
||||
existingProjection?.messageId
|
||||
?: streaming?.id
|
||||
?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-message-$it" }
|
||||
?: if (text.isNotBlank() || reasoning.isNotBlank()) "reply-failure-message-$failureId" else null
|
||||
val projection = ReplyFailureProjection(projectionMessageId, failureId, completionId)
|
||||
val existingProjectedMessage =
|
||||
projectionMessageId?.let { id -> usageState.messages.firstOrNull { it.id == id } }
|
||||
val partial =
|
||||
if (text.isNotBlank() || reasoning.isNotBlank()) {
|
||||
val base =
|
||||
streaming
|
||||
?: existingProjectedMessage
|
||||
?: ChatMessage(
|
||||
id = checkNotNull(projectionMessageId),
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
)
|
||||
base.copy(
|
||||
id = checkNotNull(projectionMessageId),
|
||||
content = text,
|
||||
reasoningText = reasoning,
|
||||
isStreaming = false,
|
||||
finishTimestamp = System.currentTimeMillis(),
|
||||
completionId = completionId,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val messagesWithoutOldProjection =
|
||||
existingProjection?.messageId?.let { oldId -> usageState.messages.filterNot { it.id == oldId } }
|
||||
?: usageState.messages
|
||||
val projectedMessages =
|
||||
partial?.let { messagesWithoutOldProjection.upsertById(it) } ?: messagesWithoutOldProjection
|
||||
// A failed partial is a transient projection of gateway `inflight`, not a
|
||||
// successful transcript row. Persisting it would erase failure provenance on
|
||||
// hydration and make a later session reopen look healthy.
|
||||
val effects = listOf(ReducerEffect.RefreshSessions, ReducerEffect.RefreshContextUsage)
|
||||
return ReducerResult(
|
||||
state =
|
||||
usageState.copy(
|
||||
messages = projectedMessages,
|
||||
replyFailure = failure.copy(id = failureId),
|
||||
replyFailureProjection = projection,
|
||||
isAgentTyping = false,
|
||||
isThinking = false,
|
||||
streamingMessage = null,
|
||||
clarifyRequest = null,
|
||||
),
|
||||
streamingState = StreamingState(),
|
||||
effects = effects,
|
||||
)
|
||||
}
|
||||
|
||||
// ── MessageDone ───────────────────────────────────────────────────
|
||||
|
||||
private fun onMessageDone(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/** Session-scoped presentation, never a send receipt or a persisted transcript row. */
|
||||
data class ReplyFailure(
|
||||
val details: String,
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
)
|
||||
|
||||
/** Stable identity for the one transient failed-reply projection in a session lifecycle. */
|
||||
data class ReplyFailureProjection(
|
||||
val messageId: String?,
|
||||
val failureId: String,
|
||||
val completionId: String? = null,
|
||||
)
|
||||
|
||||
/** Only a terminal turn failure is a failed reply; RPC/transport errors are separate. */
|
||||
internal fun replyFailureFromPayload(
|
||||
payload: Map<String, Any?>?,
|
||||
fallback: String,
|
||||
): ReplyFailure? {
|
||||
if (payload?.get("status") != "error") return null
|
||||
val surface = payload["error_surface"] as? Map<*, *>
|
||||
val details =
|
||||
buildList {
|
||||
for (key in listOf("layer", "code", "provider", "model")) {
|
||||
(surface?.get(key) as? String)?.takeIf { it.isNotBlank() }?.let { add("$key: $it") }
|
||||
}
|
||||
val error = (payload["error"] as? String)?.takeIf { it.isNotBlank() }
|
||||
add(error ?: if (payload["partial"] == true) "Unknown gateway error" else fallback)
|
||||
}.joinToString("\n")
|
||||
return ReplyFailure(sanitizeReplyErrorDetails(details))
|
||||
}
|
||||
|
||||
/** Best-effort scrubbing before display/copy/share; never includes a transcript or request payload. */
|
||||
internal fun sanitizeReplyErrorDetails(raw: String?): String {
|
||||
if (raw.isNullOrBlank()) return "Unknown gateway error"
|
||||
var safe = raw.take(8_192)
|
||||
safe = PRIVATE_KEY.replace(safe, "[REDACTED]")
|
||||
safe = HEADER.replace(safe) { "${it.groupValues[1]}[REDACTED]" }
|
||||
safe = URL.replace(safe, "[REDACTED URL]")
|
||||
safe = SECRET_ASSIGNMENT.replace(safe) { "${it.groupValues[1]}[REDACTED]" }
|
||||
safe = BEARER.replace(safe, "Bearer [REDACTED]")
|
||||
safe = PROVIDER_KEY.replace(safe, "[REDACTED]")
|
||||
return safe.take(8_192)
|
||||
}
|
||||
|
||||
private val PRIVATE_KEY = Regex("-----BEGIN [^-]*PRIVATE KEY-----[\\s\\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)")
|
||||
private val HEADER = Regex("(?im)^([ \\t]*(?:authorization|proxy-authorization|set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*")
|
||||
private val URL = Regex("(?i)(?:https?|wss?)://[^\\s<>\\\"']+")
|
||||
private val SECRET_ASSIGNMENT =
|
||||
Regex(
|
||||
"(?i)((?<![\\w-])[\\\"']?[\\w-]{0,128}" +
|
||||
"(?:token|secret|password|passwd|api[_-]?key|authorization|cookie)[\\w-]{0,128}" +
|
||||
"[\\\"']?\\s*[:=]\\s*)(?:\\\"[^\\\"]*\\\"?|'[^']*'?|[^\\r\\n,;}]+)",
|
||||
)
|
||||
private val BEARER = Regex("(?i)\\bBearer\\s+[A-Za-z0-9._~+/=-]+")
|
||||
private val PROVIDER_KEY = Regex("\\b(?:sk-[A-Za-z0-9_-]+|gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)\\b")
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.ReplyFailure
|
||||
import com.m57.hermescontrol.ui.chat.sanitizeReplyErrorDetails
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ReplyErrorCard(
|
||||
failure: ReplyFailure,
|
||||
onDismiss: () -> Unit,
|
||||
onOpenLogs: () -> Unit,
|
||||
onCopy: (String) -> Unit,
|
||||
onShare: (String) -> Unit,
|
||||
) {
|
||||
var expanded by rememberSaveable(failure.id) { mutableStateOf(false) }
|
||||
|
||||
val details = remember(failure.details) { sanitizeReplyErrorDetails(failure.details) }
|
||||
val detailsStateDescription =
|
||||
stringResource(
|
||||
if (expanded) {
|
||||
R.string.chat_reply_failed_details_expanded
|
||||
} else {
|
||||
R.string.chat_reply_failed_details_collapsed
|
||||
},
|
||||
)
|
||||
val colors = LocalHermesStatusColors.current
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("reply_error_card"),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = colors.errorContainer,
|
||||
contentColor = colors.onErrorContainer,
|
||||
border = BorderStroke(1.dp, colors.error),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.ErrorOutline, contentDescription = null)
|
||||
Text(
|
||||
stringResource(R.string.chat_reply_failed_title),
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(
|
||||
1f,
|
||||
).padding(start = 8.dp)
|
||||
.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.chat_reply_failed_dismiss))
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.chat_reply_failed_description), style = MaterialTheme.typography.bodyMedium)
|
||||
TextButton(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier.semantics { stateDescription = detailsStateDescription },
|
||||
) {
|
||||
Icon(if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = null)
|
||||
Text(stringResource(R.string.chat_reply_failed_details))
|
||||
}
|
||||
if (expanded) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
details,
|
||||
modifier = Modifier.heightIn(max = 200.dp).verticalScroll(rememberScrollState()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.chat_reply_failed_share_note), style = MaterialTheme.typography.bodySmall)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = onOpenLogs) { Text(stringResource(R.string.chat_reply_failed_logs)) }
|
||||
OutlinedButton(onClick = { onCopy(details) }) { Text(stringResource(R.string.chat_reply_failed_copy)) }
|
||||
OutlinedButton(
|
||||
onClick = { onShare(details) },
|
||||
enabled = expanded,
|
||||
) { Text(stringResource(R.string.chat_reply_failed_share)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,8 +104,9 @@ fun FullBleedChatList(
|
||||
hasOlderMessages: Boolean = false,
|
||||
pagingSessionId: String? = null,
|
||||
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
|
||||
replyErrorContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
|
||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -145,6 +146,7 @@ fun FullBleedChatList(
|
||||
// A single ordered definition supplies both emitted tail rows and anchor keys.
|
||||
val tailItems =
|
||||
buildMap<String, @Composable () -> Unit> {
|
||||
replyErrorContent?.let { put("reply_error", it) }
|
||||
agentStatus?.let { status ->
|
||||
put("agent_status") {
|
||||
AgentStatusIndicator(status = status)
|
||||
|
||||
Reference in new issue
Block a user