Added official hermes-gateway status messages (new comment category instead of tool calls and messages)
This commit is contained in:
1 parent
e9e1aed0f2
commit
86a4c8ee70
7 files changed
+327
-22
No files matched your search
@@ -22,6 +22,7 @@ import iris.protocol.TYPE_TOOL_START
|
||||
import iris.protocol.ToolEndPayload
|
||||
import iris.protocol.ToolProgressPayload
|
||||
import iris.protocol.ToolStartPayload
|
||||
import iris.util.nowMillis
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -50,7 +51,9 @@ enum class MsgStatus {
|
||||
Failed, // send failed (error frame); tap the bubble to retry
|
||||
}
|
||||
|
||||
/** A chat message (user / assistant / commentary / streaming bubble). */
|
||||
/** A chat message (user / assistant / commentary / streaming bubble).
|
||||
* [isSystem] marks a locally generated, centered notice (gateway restart /
|
||||
* online) — not a user or agent turn, not selectable or deletable. */
|
||||
data class MessageItem(
|
||||
override val id: String,
|
||||
val role: String,
|
||||
@@ -64,6 +67,7 @@ data class MessageItem(
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
val media: List<MediaItem> = emptyList(),
|
||||
val isSystem: Boolean = false,
|
||||
) : ChatItem
|
||||
|
||||
/**
|
||||
@@ -159,6 +163,17 @@ class ChatStore {
|
||||
return id
|
||||
}
|
||||
|
||||
/** Append a locally generated, centered system notice (gateway restart /
|
||||
* online) to [lane]. Not a user or agent turn: it is not selectable,
|
||||
* deletable, or echoed to the server. */
|
||||
fun addSystemMessage(lane: String, text: String) {
|
||||
localSeq++
|
||||
val id = "sys_$localSeq"
|
||||
updateLane(lane) {
|
||||
it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Frame reconciliation ──────────────────────────────────────────────
|
||||
|
||||
/** Reconcile a server frame into the cache (routed by chat/thread lane). */
|
||||
@@ -368,6 +383,14 @@ class ChatStore {
|
||||
|
||||
private fun onCommentary(lane: String, frame: Frame) {
|
||||
val p = frame.payloadAs<CommentaryPayload>() ?: return
|
||||
// Gateway lifecycle notices (restart / shutdown / online) are rendered
|
||||
// as a centered system notice by the controller, on the down/up state
|
||||
// transition. The server also emits the "restarting" notice as a
|
||||
// commentary frame (and the sync catch-up can replay it *after* the
|
||||
// local "online" notice), so drop it here: the app is the source of
|
||||
// truth for these notices, which keeps the order (restarting → online)
|
||||
// and prevents duplicates.
|
||||
if (isGatewayLifecycleNotice(p.text)) return
|
||||
updateLane(lane) { list ->
|
||||
if (list.any { it.id == p.messageId }) list
|
||||
else list + MessageItem(
|
||||
@@ -377,6 +400,14 @@ class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** True for hermes gateway lifecycle notices (same wording on all apps). */
|
||||
private fun isGatewayLifecycleNotice(text: String): Boolean {
|
||||
val t = text.trim()
|
||||
return t.contains("Gateway restarting") ||
|
||||
t.contains("Gateway shutting down") ||
|
||||
t.contains("Gateway online")
|
||||
}
|
||||
|
||||
// ── M4: media.offer (agent produced media; pull it) ─────────────────────
|
||||
|
||||
/**
|
||||
@@ -464,6 +495,31 @@ class ChatStore {
|
||||
if (changed) _lanes.value = map
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize in-flight items after the gateway goes away (restart / network
|
||||
* drop): open tool cards are closed as interrupted and live streaming
|
||||
* bubbles are stopped, so nothing spins or streams forever. The in-flight
|
||||
* turn is gone server-side, so its terminal frames (tool.end /
|
||||
* message.stop) will never arrive to close them.
|
||||
*/
|
||||
fun finalizeInterrupted() {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated = list.map { item ->
|
||||
when (item) {
|
||||
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
|
||||
is MessageItem -> if (item.streaming) item.copy(streaming = false) else item
|
||||
}
|
||||
}
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) _lanes.value = map
|
||||
}
|
||||
|
||||
/** M7: mark all pending user messages as failed (gateway error frame). */
|
||||
fun failPending() {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
|
||||
@@ -392,7 +392,10 @@ class IrisController(
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
var prevState: GatewayClient.State? = null
|
||||
client.state.collect { s ->
|
||||
val prev = prevState
|
||||
prevState = s
|
||||
if (s is GatewayClient.State.Connected) {
|
||||
channels.setAll(s.channels)
|
||||
val home = s.channels.firstOrNull { it.isDefault }?.chatId
|
||||
@@ -412,6 +415,23 @@ class IrisController(
|
||||
}
|
||||
// M5: a deep link tapped before we were connected.
|
||||
applyDeepLink()
|
||||
// Gateway came back after a restart -> announce it (hermes
|
||||
// routine, same icon + wording on all platforms). The core
|
||||
// does not send a startup/online notice to this platform, so
|
||||
// the app adds it.
|
||||
if (prev is GatewayClient.State.Reconnecting) {
|
||||
chat.addSystemMessage(homeChannel.value, GATEWAY_ONLINE_MSG)
|
||||
}
|
||||
} else if (s is GatewayClient.State.Reconnecting && prev is GatewayClient.State.Connected) {
|
||||
// Gateway went away (restart / network drop): announce it
|
||||
// (hermes routine, same icon + wording on all platforms) and
|
||||
// close the in-flight turn's dangling tool cards / streaming
|
||||
// bubble (nothing spins forever). The app is the source of
|
||||
// truth for the "restarting" notice (the server's commentary
|
||||
// frame is dropped in ChatStore), so the order is guaranteed:
|
||||
// restarting (here) before online (on reconnect).
|
||||
chat.addSystemMessage(homeChannel.value, GATEWAY_RESTARTING_MSG)
|
||||
chat.finalizeInterrupted()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,6 +671,17 @@ private fun HistoryMessage.toMessageItem(): MessageItem =
|
||||
},
|
||||
)
|
||||
|
||||
// Gateway restart routine (hermes, same icon + wording on all platforms). The
|
||||
// app generates both notices locally, on the down/up state transition, so the
|
||||
// order is guaranteed (restarting before online) and there is no dependency on
|
||||
// the server frame (which may be dropped on shutdown or replayed out of order
|
||||
// by the sync catch-up). The core does not send a startup/online notice to this
|
||||
// platform, and its "restarting" commentary frame is dropped in ChatStore.
|
||||
private const val GATEWAY_RESTARTING_MSG =
|
||||
"⚠️ Gateway restarting — Your current task will be interrupted. Send any message after restart and I'll try to resume where you left off."
|
||||
private const val GATEWAY_ONLINE_MSG =
|
||||
"♻️ Gateway online — Hermes is back and ready."
|
||||
|
||||
/** How much tool detail to show (Settings → "Tool detail"). */
|
||||
enum class ToolDetail {
|
||||
EVERYTHING, // name + full args (collapsible) + output preview
|
||||
|
||||
@@ -385,25 +385,29 @@ fun ChatScreen(controller: IrisController) {
|
||||
is ChatRow.Day -> DaySeparator(row.label)
|
||||
is ChatRow.Item -> when (val item = row.item) {
|
||||
is MessageItem -> {
|
||||
// Only finalized messages are selectable: a
|
||||
// streaming bubble has no final id yet and a
|
||||
// pending echo isn't on the server to delete.
|
||||
val selectable = !item.streaming && !item.pending
|
||||
MessageBubble(
|
||||
msg = item,
|
||||
maxWidth = bubbleMaxWidth,
|
||||
reasoningAutoCollapse = reasoningAutoCollapse,
|
||||
onRetry = { controller.retrySend(item.id) },
|
||||
selectionMode = selectionMode && selectable,
|
||||
selected = selectable && item.id in selectedIds,
|
||||
onToggleSelect = { if (selectable) toggleSelect(item.id) },
|
||||
onLongPress = {
|
||||
if (selectable) {
|
||||
if (selectionMode) toggleSelect(item.id)
|
||||
else enterSelection(item.id)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (item.isSystem) {
|
||||
SystemMessage(item)
|
||||
} else {
|
||||
// Only finalized messages are selectable: a
|
||||
// streaming bubble has no final id yet and a
|
||||
// pending echo isn't on the server to delete.
|
||||
val selectable = !item.streaming && !item.pending
|
||||
MessageBubble(
|
||||
msg = item,
|
||||
maxWidth = bubbleMaxWidth,
|
||||
reasoningAutoCollapse = reasoningAutoCollapse,
|
||||
onRetry = { controller.retrySend(item.id) },
|
||||
selectionMode = selectionMode && selectable,
|
||||
selected = selectable && item.id in selectedIds,
|
||||
onToggleSelect = { if (selectable) toggleSelect(item.id) },
|
||||
onLongPress = {
|
||||
if (selectable) {
|
||||
if (selectionMode) toggleSelect(item.id)
|
||||
else enterSelection(item.id)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
is ToolItem -> if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail)
|
||||
}
|
||||
@@ -852,6 +856,23 @@ private fun DaySeparator(label: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Centered system notice (gateway restart / online). Not a chat bubble: it is
|
||||
* a locally generated, non-selectable line, styled like a date separator but
|
||||
* with the notice text (which carries its own ⚠️ / ♻️ glyph). */
|
||||
@Composable
|
||||
private fun SystemMessage(msg: MessageItem) {
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(IrisColors.panel)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(msg.text, fontSize = 12.sp, color = IrisColors.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** M7: header title pill — letter avatar + channel name + "Bot" subtitle. */
|
||||
@Composable
|
||||
private fun TitlePill(channelName: String, modifier: Modifier = Modifier) {
|
||||
|
||||
@@ -9,6 +9,9 @@ expect fun formatTime(epochMillis: Long): String
|
||||
/** Local calendar-day key used to detect date changes between messages (M7). */
|
||||
expect fun localDayKey(epochMillis: Long): String
|
||||
|
||||
/** Current wall-clock time in epoch milliseconds (for locally generated items). */
|
||||
expect fun nowMillis(): Long
|
||||
|
||||
/** Host part of a pairing URL ("ws://host:port/ws" -> "host:port"). */
|
||||
fun hostFromUrl(url: String): String {
|
||||
val noScheme = url.trim().substringAfter("://")
|
||||
|
||||
@@ -24,4 +24,6 @@ actual fun formatTime(epochMillis: Long): String {
|
||||
}
|
||||
|
||||
actual fun localDayKey(epochMillis: Long): String =
|
||||
Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).toLocalDate().toString()
|
||||
Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).toLocalDate().toString()
|
||||
|
||||
actual fun nowMillis(): Long = System.currentTimeMillis()
|
||||
Reference in new issue
Block a user