Runtime footer: app-controlled model/context/cwd/latency/cost under replies
Hermes can append a text "runtime footer" (model, context %, workdir, latency, cost) to final replies, but only when display.runtime_footer is enabled in the hermes config. We want the same info but controlled by the APP, not the gateway config. So the gateway now ALWAYS sends the data as a structured `runtime` object on final assistant messages, and the app decides whether/what to show. Gateway (gateway-plugin/): - protocol.py: new runtime_footer() helper + `runtime` field on the message / message.stop frames. Keys (all optional, absent when the data is unavailable — e.g. no cost for local models): model (vendor prefix dropped), context_pct (0-100), cwd (home-relative), latency (seconds), cost (USD). - adapter.py: a post_api_request plugin hook captures the turn's model + prompt tokens + start time (platform-filtered to android so other platforms don't pollute the buffer). _build_runtime_footer() resolves the model's context window (cached, best-effort, off the event loop via asyncio.to_thread with a timeout) and computes context_pct. The runtime object is attached on every final send (streaming message.stop and non-streaming message, plus the fallback paths). - outbox.py: `runtime` preserved in history reconstruction so the footer survives a restart / first open. App (app/shared/): - Protocol.kt: RuntimeMeta data class + `runtime` on MessagePayload / MessageStopPayload / HistoryMessage. - ChatStore.kt: `runtime` on MessageItem, wired through live + history reconciliation. - SecureStore.kt (+ Android/Desktop actuals): runtimeFooterEnabled + runtimeFooterFields (persisted per device). - IrisController.kt: StateFlows + toggleRuntimeFooter() / toggleRuntimeField(); RUNTIME_FIELD_KEYS / default set / parser. - SettingsScreen.kt: "Runtime footer" switch; when on, an expandable chip menu (Model · Context % · Workdir · Latency · Cost) to pick fields. - ChatScreen.kt: footer rendered on the SAME line as the timestamp (footer left, time right, Telegram-style), only for final non-streaming assistant answers; Inspector pane now shows the runtime fields too. Docs: 04-wire-protocol.md + frames.schema.json document the `runtime` object. Verified end-to-end on device: final replies carry `qwen3.8-27B-exl3-4.5bpw · 53% · ~ · 38s` with the time right-aligned on the same line; 69/69 gateway tests pass, Kotlin builds + tests pass.
This commit is contained in:
1 parent
678c0344c8
commit
9286937e2d
13 files changed
+979
-290
No files matched your search
@@ -162,6 +162,14 @@ class AndroidSecureStore(
|
||||
get() = prefs.getFloat(KEY_FONT_SIZE_SCALE, 1.0f)
|
||||
set(value) = prefs.edit().putFloat(KEY_FONT_SIZE_SCALE, value).apply()
|
||||
|
||||
override var runtimeFooterEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_RUNTIME_FOOTER_ENABLED, false)
|
||||
set(value) = prefs.edit().putBoolean(KEY_RUNTIME_FOOTER_ENABLED, value).apply()
|
||||
|
||||
override var runtimeFooterFields: String
|
||||
get() = prefs.getString(KEY_RUNTIME_FOOTER_FIELDS, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_RUNTIME_FOOTER_FIELDS, value).apply()
|
||||
|
||||
override fun savePairing(
|
||||
url: String,
|
||||
token: String,
|
||||
@@ -199,5 +207,7 @@ class AndroidSecureStore(
|
||||
const val KEY_BACKGROUND_COLOR = "background_color"
|
||||
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
|
||||
const val KEY_FONT_SIZE_SCALE = "font_size_scale"
|
||||
const val KEY_RUNTIME_FOOTER_ENABLED = "runtime_footer_enabled"
|
||||
const val KEY_RUNTIME_FOOTER_FIELDS = "runtime_footer_fields"
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import iris.protocol.MessageStopPayload
|
||||
import iris.protocol.MessageUpdatePayload
|
||||
import iris.protocol.ROLE_ASSISTANT
|
||||
import iris.protocol.ROLE_USER
|
||||
import iris.protocol.RuntimeMeta
|
||||
import iris.protocol.TYPE_COMMENTARY
|
||||
import iris.protocol.TYPE_MEDIA_OFFER
|
||||
import iris.protocol.TYPE_MESSAGE
|
||||
@@ -45,10 +46,10 @@ sealed interface ChatItem {
|
||||
|
||||
/** Delivery status of a user message (M5: read.receipt; M7: failed sends). */
|
||||
enum class MsgStatus {
|
||||
Pending, // optimistic, not yet acknowledged by the gateway
|
||||
Sent, // gateway accepted it (echo received)
|
||||
Read, // agent received and started processing it (read.receipt)
|
||||
Failed, // send failed (error frame); tap the bubble to retry
|
||||
Pending, // optimistic, not yet acknowledged by the gateway
|
||||
Sent, // gateway accepted it (echo received)
|
||||
Read, // agent received and started processing it (read.receipt)
|
||||
Failed, // send failed (error frame); tap the bubble to retry
|
||||
}
|
||||
|
||||
/** A chat message (user / assistant / commentary / streaming bubble).
|
||||
@@ -66,6 +67,7 @@ data class MessageItem(
|
||||
val streaming: Boolean = false,
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
val runtime: RuntimeMeta? = null,
|
||||
val media: List<MediaItem> = emptyList(),
|
||||
val isSystem: Boolean = false,
|
||||
) : ChatItem
|
||||
@@ -119,16 +121,17 @@ class ChatStore {
|
||||
companion object {
|
||||
const val DEFAULT_LANE = "android:default"
|
||||
|
||||
fun randomId(prefix: String): String =
|
||||
"${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||
fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||
}
|
||||
|
||||
// ── Lane helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because
|
||||
* chat ids already contain a single `:` (e.g. `android:chan_1`). */
|
||||
fun laneKey(chatId: String, threadId: String?): String =
|
||||
if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId"
|
||||
fun laneKey(
|
||||
chatId: String,
|
||||
threadId: String?,
|
||||
): String = if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId"
|
||||
|
||||
/** Split a lane key back into (chatId, threadId). */
|
||||
fun parseLane(key: String): Pair<String, String?> {
|
||||
@@ -145,7 +148,10 @@ class ChatStore {
|
||||
return laneKey(chatId, frame.threadId)
|
||||
}
|
||||
|
||||
private fun updateLane(lane: String, transform: (List<ChatItem>) -> List<ChatItem>) {
|
||||
private fun updateLane(
|
||||
lane: String,
|
||||
transform: (List<ChatItem>) -> List<ChatItem>,
|
||||
) {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
map[lane] = transform(map[lane].orEmpty())
|
||||
_lanes.value = map
|
||||
@@ -154,7 +160,11 @@ class ChatStore {
|
||||
// ── Optimistic send ───────────────────────────────────────────────────
|
||||
|
||||
/** Optimistic add: show the user's message immediately (pending) in [lane]. */
|
||||
fun addPending(text: String, lane: String, media: List<MediaItem> = emptyList()): String {
|
||||
fun addPending(
|
||||
text: String,
|
||||
lane: String,
|
||||
media: List<MediaItem> = emptyList(),
|
||||
): String {
|
||||
localSeq++
|
||||
val id = "local_$localSeq"
|
||||
updateLane(lane) {
|
||||
@@ -166,7 +176,10 @@ class ChatStore {
|
||||
/** 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) {
|
||||
fun addSystemMessage(
|
||||
lane: String,
|
||||
text: String,
|
||||
) {
|
||||
localSeq++
|
||||
val id = "sys_$localSeq"
|
||||
updateLane(lane) {
|
||||
@@ -195,7 +208,10 @@ class ChatStore {
|
||||
|
||||
// ── message (final / standalone, incl. non-streaming + user echo) ─────
|
||||
|
||||
private fun onMessage(lane: String, frame: Frame) {
|
||||
private fun onMessage(
|
||||
lane: String,
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<MessagePayload>() ?: return
|
||||
// Auto-threaded send: the gateway created a fresh thread for this
|
||||
// message; the optimistic bubble is still in the parent flat lane.
|
||||
@@ -205,99 +221,143 @@ class ChatStore {
|
||||
val byId = list.indexOfFirst { it.id == p.messageId }
|
||||
if (byId >= 0) {
|
||||
val cur = list[byId] as? MessageItem ?: return@updateLane list
|
||||
val updated = cur.copy(
|
||||
text = p.text,
|
||||
reasoning = p.reasoning,
|
||||
pending = false,
|
||||
streaming = false,
|
||||
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
|
||||
model = p.model,
|
||||
tokens = p.tokens,
|
||||
ts = p.ts ?: cur.ts,
|
||||
media = mergeMedia(cur.media, p.media),
|
||||
)
|
||||
val updated =
|
||||
cur.copy(
|
||||
text = p.text,
|
||||
reasoning = p.reasoning,
|
||||
pending = false,
|
||||
streaming = false,
|
||||
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
|
||||
model = p.model,
|
||||
tokens = p.tokens,
|
||||
runtime = p.runtime ?: cur.runtime,
|
||||
ts = p.ts ?: cur.ts,
|
||||
media = mergeMedia(cur.media, p.media),
|
||||
)
|
||||
list.toMutableList().also { it[byId] = updated }
|
||||
} else if (p.role == ROLE_USER) {
|
||||
// Replace the matching optimistic pending bubble (server echo).
|
||||
val pendingIdx = list.indexOfLast {
|
||||
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
|
||||
}
|
||||
val pendingIdx =
|
||||
list.indexOfLast {
|
||||
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
|
||||
}
|
||||
if (pendingIdx >= 0) {
|
||||
list.toMutableList().also {
|
||||
it[pendingIdx] = MessageItem(
|
||||
id = p.messageId, role = p.role, text = p.text,
|
||||
ts = p.ts ?: 0, reasoning = p.reasoning,
|
||||
media = mergeMedia((list[pendingIdx] as MessageItem).media, p.media),
|
||||
)
|
||||
it[pendingIdx] =
|
||||
MessageItem(
|
||||
id = p.messageId,
|
||||
role = p.role,
|
||||
text = p.text,
|
||||
ts = p.ts ?: 0,
|
||||
reasoning = p.reasoning,
|
||||
media = mergeMedia((list[pendingIdx] as MessageItem).media, p.media),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
list + MessageItem(
|
||||
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
|
||||
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
|
||||
media = p.media.map { it.toMediaItem() },
|
||||
)
|
||||
list +
|
||||
MessageItem(
|
||||
id = p.messageId,
|
||||
role = p.role,
|
||||
text = p.text,
|
||||
ts = p.ts ?: 0,
|
||||
reasoning = p.reasoning,
|
||||
model = p.model,
|
||||
tokens = p.tokens,
|
||||
runtime = p.runtime,
|
||||
media = p.media.map { it.toMediaItem() },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
list + MessageItem(
|
||||
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
|
||||
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
|
||||
media = p.media.map { it.toMediaItem() },
|
||||
)
|
||||
list +
|
||||
MessageItem(
|
||||
id = p.messageId,
|
||||
role = p.role,
|
||||
text = p.text,
|
||||
ts = p.ts ?: 0,
|
||||
reasoning = p.reasoning,
|
||||
model = p.model,
|
||||
tokens = p.tokens,
|
||||
runtime = p.runtime,
|
||||
media = p.media.map { it.toMediaItem() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the matching optimistic user bubble from the parent flat lane of
|
||||
* [lane] (an auto-created thread). The gateway moved the message into the
|
||||
* fresh thread, so the pending bubble must follow it; the echo then replaces
|
||||
* it in the thread lane. No-op when [lane] is not a thread lane or no
|
||||
* pending bubble with the same text exists in the flat lane.
|
||||
*/
|
||||
private fun relocatePendingToThreadLane(lane: String, p: MessagePayload) {
|
||||
* Remove the matching optimistic user bubble from the parent flat lane of
|
||||
* [lane] (an auto-created thread). The gateway moved the message into the
|
||||
* fresh thread, so the pending bubble must follow it; the echo then replaces
|
||||
* it in the thread lane. No-op when [lane] is not a thread lane or no
|
||||
* pending bubble with the same text exists in the flat lane.
|
||||
*/
|
||||
private fun relocatePendingToThreadLane(
|
||||
lane: String,
|
||||
p: MessagePayload,
|
||||
) {
|
||||
val (chatId, threadId) = parseLane(lane)
|
||||
if (threadId == null) return
|
||||
val flatLane = chatId
|
||||
val map = _lanes.value.toMutableMap()
|
||||
val flatList = map[flatLane].orEmpty()
|
||||
val idx = flatList.indexOfLast {
|
||||
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
|
||||
}
|
||||
val idx =
|
||||
flatList.indexOfLast {
|
||||
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
|
||||
}
|
||||
if (idx < 0) return
|
||||
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
|
||||
_lanes.value = map
|
||||
}
|
||||
|
||||
/** Merge server media refs into existing items, keeping local paths. */
|
||||
private fun mergeMedia(existing: List<MediaItem>, incoming: List<MediaRef>): List<MediaItem> {
|
||||
private fun mergeMedia(
|
||||
existing: List<MediaItem>,
|
||||
incoming: List<MediaRef>,
|
||||
): List<MediaItem> {
|
||||
if (incoming.isEmpty()) return existing
|
||||
val byId = existing.associateBy { it.mediaId }
|
||||
return incoming.map { ref ->
|
||||
byId[ref.mediaId]?.copy(
|
||||
kind = ref.kind, mime = ref.mime, size = ref.size, filename = ref.filename,
|
||||
kind = ref.kind,
|
||||
mime = ref.mime,
|
||||
size = ref.size,
|
||||
filename = ref.filename,
|
||||
) ?: ref.toMediaItem()
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaRef.toMediaItem() =
|
||||
MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
|
||||
private fun MediaRef.toMediaItem() = MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
|
||||
|
||||
// ── message.start (open a live streaming bubble) ──────────────────────
|
||||
|
||||
private fun onMessageStart(lane: String, frame: Frame) {
|
||||
private fun onMessageStart(
|
||||
lane: String,
|
||||
frame: Frame,
|
||||
) {
|
||||
if (!streamingEnabled) return
|
||||
val p = frame.payloadAs<MessageStartPayload>() ?: return
|
||||
updateLane(lane) { list ->
|
||||
if (list.any { it.id == p.messageId }) list
|
||||
else list + MessageItem(
|
||||
id = p.messageId, role = p.role, text = "", ts = 0, streaming = true,
|
||||
)
|
||||
if (list.any { it.id == p.messageId }) {
|
||||
list
|
||||
} else {
|
||||
list +
|
||||
MessageItem(
|
||||
id = p.messageId,
|
||||
role = p.role,
|
||||
text = "",
|
||||
ts = 0,
|
||||
streaming = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── message.update (replace live bubble text; full snapshot) ──────────
|
||||
|
||||
private fun onMessageUpdate(lane: String, frame: Frame) {
|
||||
private fun onMessageUpdate(
|
||||
lane: String,
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<MessageUpdatePayload>() ?: return
|
||||
updateLane(lane) { list ->
|
||||
val idx = list.indexOfFirst { it.id == p.messageId }
|
||||
@@ -309,28 +369,39 @@ class ChatStore {
|
||||
|
||||
// ── message.stop (finalize the live bubble) ───────────────────────────
|
||||
|
||||
private fun onMessageStop(lane: String, frame: Frame) {
|
||||
private fun onMessageStop(
|
||||
lane: String,
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<MessageStopPayload>() ?: return
|
||||
updateLane(lane) { list ->
|
||||
val idx = list.indexOfFirst { it.id == p.messageId }
|
||||
if (idx < 0) {
|
||||
// No live bubble (e.g. missed start) — materialize a final one.
|
||||
list + MessageItem(
|
||||
id = p.messageId, role = ROLE_ASSISTANT, text = p.finalText,
|
||||
ts = p.ts ?: 0, reasoning = p.reasoning,
|
||||
model = p.model, tokens = p.tokens,
|
||||
)
|
||||
} else {
|
||||
val cur = list[idx] as? MessageItem ?: return@updateLane list
|
||||
list.toMutableList().also {
|
||||
it[idx] = cur.copy(
|
||||
list +
|
||||
MessageItem(
|
||||
id = p.messageId,
|
||||
role = ROLE_ASSISTANT,
|
||||
text = p.finalText,
|
||||
ts = p.ts ?: 0,
|
||||
reasoning = p.reasoning,
|
||||
model = p.model,
|
||||
tokens = p.tokens,
|
||||
streaming = false,
|
||||
ts = p.ts ?: cur.ts,
|
||||
runtime = p.runtime,
|
||||
)
|
||||
} else {
|
||||
val cur = list[idx] as? MessageItem ?: return@updateLane list
|
||||
list.toMutableList().also {
|
||||
it[idx] =
|
||||
cur.copy(
|
||||
text = p.finalText,
|
||||
reasoning = p.reasoning,
|
||||
model = p.model,
|
||||
tokens = p.tokens,
|
||||
runtime = p.runtime ?: cur.runtime,
|
||||
streaming = false,
|
||||
ts = p.ts ?: cur.ts,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,21 +409,31 @@ class ChatStore {
|
||||
|
||||
// ── tool.start (new tool card) ────────────────────────────────────────
|
||||
|
||||
private fun onToolStart(lane: String, frame: Frame) {
|
||||
private fun onToolStart(
|
||||
lane: String,
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<ToolStartPayload>() ?: return
|
||||
toolSeq++
|
||||
val id = "tool_$toolSeq"
|
||||
updateLane(lane) { list ->
|
||||
list + ToolItem(
|
||||
id = id, index = p.index, name = p.name,
|
||||
preview = p.preview, args = p.args,
|
||||
)
|
||||
list +
|
||||
ToolItem(
|
||||
id = id,
|
||||
index = p.index,
|
||||
name = p.name,
|
||||
preview = p.preview,
|
||||
args = p.args,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── tool.progress (in-progress note) ──────────────────────────────────
|
||||
|
||||
private fun onToolProgress(lane: String, frame: Frame) {
|
||||
private fun onToolProgress(
|
||||
lane: String,
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<ToolProgressPayload>() ?: return
|
||||
updateLane(lane) { list ->
|
||||
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
|
||||
@@ -364,24 +445,33 @@ class ChatStore {
|
||||
|
||||
// ── tool.end (mark the tool card complete) ────────────────────────────
|
||||
|
||||
private fun onToolEnd(lane: String, frame: Frame) {
|
||||
private fun onToolEnd(
|
||||
lane: String,
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<ToolEndPayload>() ?: return
|
||||
updateLane(lane) { list ->
|
||||
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
|
||||
if (idx < 0) return@updateLane list
|
||||
val cur = list[idx] as ToolItem
|
||||
list.toMutableList().also {
|
||||
it[idx] = cur.copy(
|
||||
done = true, ok = p.ok, duration = p.duration,
|
||||
outputPreview = p.outputPreview,
|
||||
)
|
||||
it[idx] =
|
||||
cur.copy(
|
||||
done = true,
|
||||
ok = p.ok,
|
||||
duration = p.duration,
|
||||
outputPreview = p.outputPreview,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── commentary (dimmed interim beat) ──────────────────────────────────
|
||||
|
||||
private fun onCommentary(lane: String, frame: Frame) {
|
||||
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
|
||||
@@ -392,11 +482,18 @@ class ChatStore {
|
||||
// and prevents duplicates.
|
||||
if (isGatewayLifecycleNotice(p.text)) return
|
||||
updateLane(lane) { list ->
|
||||
if (list.any { it.id == p.messageId }) list
|
||||
else list + MessageItem(
|
||||
id = p.messageId, role = ROLE_ASSISTANT, text = p.text,
|
||||
ts = 0, isCommentary = true,
|
||||
)
|
||||
if (list.any { it.id == p.messageId }) {
|
||||
list
|
||||
} else {
|
||||
list +
|
||||
MessageItem(
|
||||
id = p.messageId,
|
||||
role = ROLE_ASSISTANT,
|
||||
text = p.text,
|
||||
ts = 0,
|
||||
isCommentary = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,20 +512,28 @@ class ChatStore {
|
||||
* falling back to the lane's last assistant message). The item has no local
|
||||
* path yet — the controller pulls it and calls [setMediaLocalPath].
|
||||
*/
|
||||
private fun onMediaOffer(lane: String, frame: Frame) {
|
||||
private fun onMediaOffer(
|
||||
lane: String,
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<MediaOfferPayload>() ?: return
|
||||
updateLane(lane) { list ->
|
||||
// Already attached (offer replayed)? Skip.
|
||||
if (list.any { it is MessageItem && it.media.any { m -> m.mediaId == p.mediaId } }) return@updateLane list
|
||||
val item = MediaItem(
|
||||
mediaId = p.mediaId, kind = p.kind, mime = p.mime,
|
||||
size = p.size, filename = p.filename,
|
||||
)
|
||||
val targetIdx = if (p.messageId != null) {
|
||||
list.indexOfFirst { it.id == p.messageId }
|
||||
} else {
|
||||
list.indexOfLast { it is MessageItem && it.role == ROLE_ASSISTANT }
|
||||
}
|
||||
val item =
|
||||
MediaItem(
|
||||
mediaId = p.mediaId,
|
||||
kind = p.kind,
|
||||
mime = p.mime,
|
||||
size = p.size,
|
||||
filename = p.filename,
|
||||
)
|
||||
val targetIdx =
|
||||
if (p.messageId != null) {
|
||||
list.indexOfFirst { it.id == p.messageId }
|
||||
} else {
|
||||
list.indexOfLast { it is MessageItem && it.role == ROLE_ASSISTANT }
|
||||
}
|
||||
if (targetIdx < 0) return@updateLane list
|
||||
val cur = list[targetIdx] as? MessageItem ?: return@updateLane list
|
||||
list.toMutableList().also { it[targetIdx] = cur.copy(media = cur.media + item) }
|
||||
@@ -436,17 +541,26 @@ class ChatStore {
|
||||
}
|
||||
|
||||
/** Record a pulled file's local path for [mediaId] (all lanes). */
|
||||
fun setMediaLocalPath(mediaId: String, localPath: String) {
|
||||
fun setMediaLocalPath(
|
||||
mediaId: String,
|
||||
localPath: String,
|
||||
) {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated = list.map { item ->
|
||||
if (item is MessageItem) {
|
||||
item.copy(media = item.media.map { m ->
|
||||
if (m.mediaId == mediaId) m.copy(localPath = localPath) else m
|
||||
})
|
||||
} else item
|
||||
}
|
||||
val updated =
|
||||
list.map { item ->
|
||||
if (item is MessageItem) {
|
||||
item.copy(
|
||||
media =
|
||||
item.media.map { m ->
|
||||
if (m.mediaId == mediaId) m.copy(localPath = localPath) else m
|
||||
},
|
||||
)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
@@ -460,13 +574,16 @@ class ChatStore {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated = list.map { item ->
|
||||
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
|
||||
item.status != MsgStatus.Read
|
||||
) {
|
||||
item.copy(pending = false, status = MsgStatus.Read)
|
||||
} else item
|
||||
}
|
||||
val updated =
|
||||
list.map { item ->
|
||||
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
|
||||
item.status != MsgStatus.Read
|
||||
) {
|
||||
item.copy(pending = false, status = MsgStatus.Read)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
@@ -476,11 +593,11 @@ class ChatStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove messages by id from every lane (a `message.deleted` frame). The
|
||||
* server is authoritative: the frame carries no lane, and a message id is
|
||||
* unique, so scanning all lanes is both safe and idempotent (a no-op when the
|
||||
* id is absent, e.g. a local-only id another device never had).
|
||||
*/
|
||||
* Remove messages by id from every lane (a `message.deleted` frame). The
|
||||
* server is authoritative: the frame carries no lane, and a message id is
|
||||
* unique, so scanning all lanes is both safe and idempotent (a no-op when the
|
||||
* id is absent, e.g. a local-only id another device never had).
|
||||
*/
|
||||
fun removeMessages(messageIds: Set<String>) {
|
||||
if (messageIds.isEmpty()) return
|
||||
val map = _lanes.value.toMutableMap()
|
||||
@@ -506,12 +623,13 @@ class ChatStore {
|
||||
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
|
||||
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
|
||||
@@ -525,11 +643,14 @@ class ChatStore {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated = list.map { item ->
|
||||
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
|
||||
item.copy(pending = false, status = MsgStatus.Failed)
|
||||
} else item
|
||||
}
|
||||
val updated =
|
||||
list.map { item ->
|
||||
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
|
||||
item.copy(pending = false, status = MsgStatus.Failed)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
@@ -539,12 +660,17 @@ class ChatStore {
|
||||
}
|
||||
|
||||
/** M7: re-arm a failed user message for a retry send. */
|
||||
fun rearmForRetry(lane: String, messageId: String) {
|
||||
fun rearmForRetry(
|
||||
lane: String,
|
||||
messageId: String,
|
||||
) {
|
||||
updateLane(lane) { list ->
|
||||
list.map { item ->
|
||||
if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) {
|
||||
item.copy(pending = true, status = MsgStatus.Pending)
|
||||
} else item
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,12 +684,16 @@ class ChatStore {
|
||||
* the in-memory store is empty and the `sync` delta does not cover older
|
||||
* messages.
|
||||
*/
|
||||
fun loadHistory(lane: String, messages: List<MessageItem>) {
|
||||
fun loadHistory(
|
||||
lane: String,
|
||||
messages: List<MessageItem>,
|
||||
) {
|
||||
updateLane(lane) { list ->
|
||||
val historyIds = messages.map { it.id }.toSet()
|
||||
val preserved = list.filter { item ->
|
||||
item !is MessageItem || item.id !in historyIds
|
||||
}
|
||||
val preserved =
|
||||
list.filter { item ->
|
||||
item !is MessageItem || item.id !in historyIds
|
||||
}
|
||||
(messages + preserved).sortedBy { item ->
|
||||
(item as? MessageItem)?.ts?.takeIf { it > 0 } ?: Long.MAX_VALUE
|
||||
}
|
||||
@@ -573,4 +703,4 @@ class ChatStore {
|
||||
fun clear() {
|
||||
_lanes.value = emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,15 @@ interface SecureStore {
|
||||
* system font scale). */
|
||||
var fontSizeScale: Float
|
||||
|
||||
/** UI setting: show the runtime-metadata footer under final assistant
|
||||
* messages (model / context % / cwd / latency / cost). */
|
||||
var runtimeFooterEnabled: Boolean
|
||||
|
||||
/** UI setting: which runtime-footer fields to show, as a comma-separated
|
||||
* list in display order (e.g. "model,context_pct,cwd"). Empty = the
|
||||
* default set. Valid keys: model, context_pct, cwd, latency, cost. */
|
||||
var runtimeFooterFields: String
|
||||
|
||||
fun savePairing(
|
||||
url: String,
|
||||
token: String,
|
||||
|
||||
@@ -20,11 +20,12 @@ import kotlinx.serialization.json.put
|
||||
const val PROTOCOL_VERSION = 1
|
||||
|
||||
object IrisJson {
|
||||
val instance: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
isLenient = true
|
||||
}
|
||||
val instance: Json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
isLenient = true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Frame type constants ────────────────────────────────────────────────
|
||||
@@ -198,6 +199,22 @@ data class HelloAckPayload(
|
||||
|
||||
// ── message (server -> app) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Structured runtime metadata for the app's footer (mirror of the gateway's
|
||||
* `runtime` object). The gateway ALWAYS sends this on final assistant
|
||||
* messages; whether/what is shown is a per-app setting (Settings → Runtime
|
||||
* footer), NOT a hermes config. Fields are absent when the data is
|
||||
* unavailable (local models have no cost, etc.).
|
||||
*/
|
||||
@Serializable
|
||||
data class RuntimeMeta(
|
||||
val model: String? = null,
|
||||
@SerialName("context_pct") val contextPct: Int? = null,
|
||||
val cwd: String? = null,
|
||||
val latency: Double? = null,
|
||||
val cost: Double? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessagePayload(
|
||||
@SerialName("message_id") val messageId: String,
|
||||
@@ -207,6 +224,7 @@ data class MessagePayload(
|
||||
@SerialName("reply_to") val replyTo: String? = null,
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
val runtime: RuntimeMeta? = null,
|
||||
val ts: Long? = null,
|
||||
val media: List<MediaRef> = emptyList(),
|
||||
)
|
||||
@@ -260,7 +278,9 @@ data class MediaPullPayload(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaPullEndPayload(val ok: Boolean)
|
||||
data class MediaPullEndPayload(
|
||||
val ok: Boolean,
|
||||
)
|
||||
|
||||
// ── M2: streaming frames (server -> app) ────────────────────────────────
|
||||
|
||||
@@ -283,6 +303,7 @@ data class MessageStopPayload(
|
||||
val reasoning: String? = null,
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
val runtime: RuntimeMeta? = null,
|
||||
val ts: Long? = null,
|
||||
)
|
||||
|
||||
@@ -335,13 +356,20 @@ data class MessageSendPayload(
|
||||
// ── typing / error / ping ───────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class TypingPayload(val on: Boolean)
|
||||
data class TypingPayload(
|
||||
val on: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ErrorPayload(val code: String, val message: String)
|
||||
data class ErrorPayload(
|
||||
val code: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PingPayload(val ts: Long? = null)
|
||||
data class PingPayload(
|
||||
val ts: Long? = null,
|
||||
)
|
||||
|
||||
// ── M3: channel directory (app -> server requests) ──────────────────────
|
||||
|
||||
@@ -353,13 +381,19 @@ data class ChannelCreatePayload(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelRenamePayload(val name: String)
|
||||
data class ChannelRenamePayload(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelFavoritePayload(val on: Boolean)
|
||||
data class ChannelFavoritePayload(
|
||||
val on: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelSetAutomationPayload(val on: Boolean)
|
||||
data class ChannelSetAutomationPayload(
|
||||
val on: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelIconPayload(
|
||||
@@ -370,10 +404,14 @@ data class ChannelIconPayload(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList())
|
||||
data class ChannelListPayload(
|
||||
val channels: List<ChannelInfo> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelDeletedPayload(@SerialName("chat_id") val chatId: String)
|
||||
data class ChannelDeletedPayload(
|
||||
@SerialName("chat_id") val chatId: String,
|
||||
)
|
||||
|
||||
// ── M3: search ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -424,10 +462,14 @@ data class CommandsCatalogPayload(
|
||||
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class SyncPayload(val cursor: Long)
|
||||
data class SyncPayload(
|
||||
val cursor: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SyncDonePayload(val cursor: Long)
|
||||
data class SyncDonePayload(
|
||||
val cursor: Long,
|
||||
)
|
||||
|
||||
// ── history (full message history for a chat/thread) ────────────────────
|
||||
|
||||
@@ -440,6 +482,7 @@ data class HistoryMessage(
|
||||
val reasoning: String? = null,
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
val runtime: RuntimeMeta? = null,
|
||||
val ts: Long? = null,
|
||||
val media: List<MediaRef> = emptyList(),
|
||||
)
|
||||
@@ -496,7 +539,9 @@ data class ReadReceiptPayload(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatusPayload(val state: String)
|
||||
data class StatusPayload(
|
||||
val state: String,
|
||||
)
|
||||
|
||||
// ── Frame builders ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -509,16 +554,17 @@ fun helloFrame(
|
||||
): Frame =
|
||||
Frame(
|
||||
type = TYPE_HELLO,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
HelloPayload.serializer(),
|
||||
HelloPayload(
|
||||
token = token,
|
||||
deviceId = deviceId,
|
||||
deviceName = deviceName,
|
||||
fcmToken = fcmToken,
|
||||
ntfyTopic = ntfyTopic,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
HelloPayload.serializer(),
|
||||
HelloPayload(
|
||||
token = token,
|
||||
deviceId = deviceId,
|
||||
deviceName = deviceName,
|
||||
fcmToken = fcmToken,
|
||||
ntfyTopic = ntfyTopic,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun messageSendFrame(
|
||||
@@ -534,79 +580,109 @@ fun messageSendFrame(
|
||||
type = TYPE_MESSAGE_SEND,
|
||||
chatId = chatId,
|
||||
threadId = threadId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
MessageSendPayload.serializer(),
|
||||
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
MessageSendPayload.serializer(),
|
||||
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
|
||||
),
|
||||
)
|
||||
|
||||
fun pingFrame(): Frame =
|
||||
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
|
||||
fun pingFrame(): Frame = Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
|
||||
|
||||
// ── M3 frame builders ───────────────────────────────────────────────────
|
||||
|
||||
fun channelCreateFrame(id: Int, name: String, kind: String = "channel", parentChatId: String? = null): Frame =
|
||||
fun channelCreateFrame(
|
||||
id: Int,
|
||||
name: String,
|
||||
kind: String = "channel",
|
||||
parentChatId: String? = null,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_CREATE,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
ChannelCreatePayload.serializer(),
|
||||
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ChannelCreatePayload.serializer(),
|
||||
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelRenameFrame(id: Int, chatId: String, name: String): Frame =
|
||||
fun channelRenameFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
name: String,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_RENAME,
|
||||
chatId = chatId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
ChannelRenamePayload.serializer(),
|
||||
ChannelRenamePayload(name = name),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ChannelRenamePayload.serializer(),
|
||||
ChannelRenamePayload(name = name),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelSetDefaultFrame(id: Int, chatId: String): Frame =
|
||||
Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
|
||||
fun channelSetDefaultFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
): Frame = Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
|
||||
|
||||
fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame =
|
||||
fun channelFavoriteFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
on: Boolean,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_FAVORITE,
|
||||
chatId = chatId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
ChannelFavoritePayload.serializer(),
|
||||
ChannelFavoritePayload(on = on),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ChannelFavoritePayload.serializer(),
|
||||
ChannelFavoritePayload(on = on),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelSetAutomationFrame(id: Int, chatId: String, on: Boolean): Frame =
|
||||
fun channelSetAutomationFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
on: Boolean,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_SET_AUTOMATION,
|
||||
chatId = chatId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
ChannelSetAutomationPayload.serializer(),
|
||||
ChannelSetAutomationPayload(on = on),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ChannelSetAutomationPayload.serializer(),
|
||||
ChannelSetAutomationPayload(on = on),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame =
|
||||
fun channelIconFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
icon: String?,
|
||||
color: String?,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_ICON,
|
||||
chatId = chatId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
ChannelIconPayload.serializer(),
|
||||
ChannelIconPayload(icon = icon, color = color),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ChannelIconPayload.serializer(),
|
||||
ChannelIconPayload(icon = icon, color = color),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelDeleteFrame(id: Int, chatId: String): Frame =
|
||||
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
|
||||
fun channelDeleteFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
): Frame = Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
|
||||
|
||||
fun channelListFrame(id: Int): Frame =
|
||||
Frame(id = id, type = TYPE_CHANNEL_LIST)
|
||||
fun channelListFrame(id: Int): Frame = Frame(id = id, type = TYPE_CHANNEL_LIST)
|
||||
|
||||
fun searchFrame(
|
||||
id: Int,
|
||||
@@ -620,25 +696,29 @@ fun searchFrame(
|
||||
type = TYPE_SEARCH,
|
||||
chatId = chatId,
|
||||
threadId = threadId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
SearchPayload.serializer(),
|
||||
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
SearchPayload.serializer(),
|
||||
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
|
||||
),
|
||||
)
|
||||
|
||||
/** Request the gateway's slash-command catalog (the composer's "/" drawer).
|
||||
* Answered by a `commands.catalog` frame carrying the same id. */
|
||||
fun commandsCatalogFrame(id: Int): Frame =
|
||||
Frame(id = id, type = TYPE_COMMANDS_CATALOG)
|
||||
fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG)
|
||||
|
||||
fun syncFrame(id: Int, cursor: Long): Frame =
|
||||
fun syncFrame(
|
||||
id: Int,
|
||||
cursor: Long,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_SYNC,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
SyncPayload.serializer(),
|
||||
SyncPayload(cursor = cursor),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
SyncPayload.serializer(),
|
||||
SyncPayload(cursor = cursor),
|
||||
),
|
||||
)
|
||||
|
||||
/** Request a page of full message history for a chat/thread (initial open /
|
||||
@@ -656,10 +736,11 @@ fun historyFrame(
|
||||
type = TYPE_HISTORY,
|
||||
chatId = chatId,
|
||||
threadId = threadId,
|
||||
payload = buildJsonObject {
|
||||
if (beforeMessageId != null) put("before_message_id", beforeMessageId)
|
||||
put("limit", limit)
|
||||
},
|
||||
payload =
|
||||
buildJsonObject {
|
||||
if (beforeMessageId != null) put("before_message_id", beforeMessageId)
|
||||
put("limit", limit)
|
||||
},
|
||||
)
|
||||
|
||||
/** Request deletion of the given message(s) in a chat/thread. The server
|
||||
@@ -675,9 +756,10 @@ fun messageDeleteFrame(
|
||||
type = TYPE_MESSAGE_DELETE,
|
||||
chatId = chatId,
|
||||
threadId = threadId,
|
||||
payload = buildJsonObject {
|
||||
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
|
||||
},
|
||||
payload =
|
||||
buildJsonObject {
|
||||
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
|
||||
},
|
||||
)
|
||||
|
||||
// ── M4 frame builders ────────────────────────────────────────────────────
|
||||
@@ -693,39 +775,53 @@ fun mediaUploadStartFrame(
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MEDIA_UPLOAD_START,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
MediaUploadStartPayload.serializer(),
|
||||
MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
MediaUploadStartPayload.serializer(),
|
||||
MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
|
||||
),
|
||||
)
|
||||
|
||||
fun mediaUploadEndFrame(id: Int, mediaRef: String, sha256: String): Frame =
|
||||
fun mediaUploadEndFrame(
|
||||
id: Int,
|
||||
mediaRef: String,
|
||||
sha256: String,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MEDIA_UPLOAD_END,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
MediaUploadEndPayload.serializer(),
|
||||
MediaUploadEndPayload(mediaRef, sha256),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
MediaUploadEndPayload.serializer(),
|
||||
MediaUploadEndPayload(mediaRef, sha256),
|
||||
),
|
||||
)
|
||||
|
||||
fun mediaPullFrame(id: Int, mediaId: String): Frame =
|
||||
fun mediaPullFrame(
|
||||
id: Int,
|
||||
mediaId: String,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MEDIA_PULL,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
MediaPullPayload.serializer(),
|
||||
MediaPullPayload(mediaId),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
MediaPullPayload.serializer(),
|
||||
MediaPullPayload(mediaId),
|
||||
),
|
||||
)
|
||||
|
||||
// ── M5 frame builders ───────────────────────────────────────────────────
|
||||
|
||||
fun fcmRegisterFrame(fcmToken: String? = null, ntfyTopic: String? = null): Frame =
|
||||
fun fcmRegisterFrame(
|
||||
fcmToken: String? = null,
|
||||
ntfyTopic: String? = null,
|
||||
): Frame =
|
||||
Frame(
|
||||
type = TYPE_FCM_REGISTER,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
FcmRegisterPayload.serializer(),
|
||||
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
|
||||
),
|
||||
)
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
FcmRegisterPayload.serializer(),
|
||||
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
|
||||
),
|
||||
)
|
||||
@@ -146,8 +146,7 @@ class IrisController(
|
||||
|
||||
/** True while [frame] is a sync replay that already reached the device
|
||||
* via push (live frames carry no cursor and are never suppressed). */
|
||||
private fun isPushedReplay(frame: iris.protocol.Frame): Boolean =
|
||||
frame.cursor?.let { it <= lastPushedCursor } ?: false
|
||||
private fun isPushedReplay(frame: iris.protocol.Frame): Boolean = frame.cursor?.let { it <= lastPushedCursor } ?: false
|
||||
|
||||
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
|
||||
// Persisted (Settings → "Threads").
|
||||
@@ -183,6 +182,48 @@ class IrisController(
|
||||
store.reasoningAutoCollapse = _reasoningAutoCollapse.value
|
||||
}
|
||||
|
||||
// ── Runtime-metadata footer (Settings → "Runtime footer") ───────────────────────────────────────────────
|
||||
// The gateway ALWAYS sends the structured runtime metadata on final
|
||||
// assistant messages; these settings control whether/what the app shows
|
||||
// (per-device display preference, like tool verbosity). [runtimeFooterFields]
|
||||
// is a comma-separated list of field keys in display order.
|
||||
private val _runtimeFooterEnabled = MutableStateFlow(store.runtimeFooterEnabled)
|
||||
val runtimeFooterEnabled: StateFlow<Boolean> = _runtimeFooterEnabled.asStateFlow()
|
||||
|
||||
private val _runtimeFooterFields = MutableStateFlow(parseRuntimeFields(store.runtimeFooterFields))
|
||||
val runtimeFooterFields: StateFlow<List<String>> = _runtimeFooterFields.asStateFlow()
|
||||
|
||||
fun toggleRuntimeFooter() {
|
||||
_runtimeFooterEnabled.value = !_runtimeFooterEnabled.value
|
||||
store.runtimeFooterEnabled = _runtimeFooterEnabled.value
|
||||
}
|
||||
|
||||
/** Toggle a single runtime-footer field on/off (preserving order). */
|
||||
fun toggleRuntimeField(field: String) {
|
||||
val current = _runtimeFooterFields.value
|
||||
val updated = if (field in current) current - field else current + field
|
||||
_runtimeFooterFields.value = updated
|
||||
store.runtimeFooterFields = updated.joinToString(",")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FONT_SCALE_MIN = 0.8f
|
||||
const val FONT_SCALE_MAX = 1.5f
|
||||
|
||||
/** Valid runtime-footer field keys (mirror of the gateway's RUNTIME_FIELDS). */
|
||||
val RUNTIME_FIELD_KEYS = listOf("model", "context_pct", "cwd", "latency", "cost")
|
||||
|
||||
/** Default footer fields (in display order) when the user has none set. */
|
||||
val RUNTIME_FIELDS_DEFAULT = listOf("model", "context_pct", "cwd")
|
||||
|
||||
/** Parse a persisted comma-separated field list; unknown keys dropped,
|
||||
* order preserved, empty falls back to the default set. */
|
||||
fun parseRuntimeFields(raw: String): List<String> {
|
||||
val parsed = raw.split(",").map { it.trim() }.filter { it in RUNTIME_FIELD_KEYS }
|
||||
return if (parsed.isEmpty()) RUNTIME_FIELDS_DEFAULT else parsed
|
||||
}
|
||||
}
|
||||
|
||||
// ── Appearance (Settings → Appearance) ────────────────────────────────
|
||||
// User-customizable bubble colors + background (color or image).
|
||||
// Persisted as ARGB ints + an image path (see UserTheme).
|
||||
@@ -252,11 +293,6 @@ class IrisController(
|
||||
store.fontSizeScale = clamped
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FONT_SCALE_MIN = 0.8f
|
||||
const val FONT_SCALE_MAX = 1.5f
|
||||
}
|
||||
|
||||
// ── M3: search state ──────────────────────────────────────────────────
|
||||
private val _searchResults = MutableStateFlow<List<SearchHit>>(emptyList())
|
||||
val searchResults: StateFlow<List<SearchHit>> = _searchResults.asStateFlow()
|
||||
@@ -915,6 +951,7 @@ private fun HistoryMessage.toMessageItem(): MessageItem =
|
||||
reasoning = reasoning,
|
||||
model = model,
|
||||
tokens = tokens,
|
||||
runtime = runtime,
|
||||
media =
|
||||
media.map {
|
||||
MediaItem(mediaId = it.mediaId, kind = it.kind, mime = it.mime, size = it.size, filename = it.filename)
|
||||
|
||||
@@ -141,6 +141,7 @@ import iris.util.prepareForMarkdown
|
||||
import iris.util.preserveNewlinesAsHardBreaks
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Chat screen (M3): channel drawer + topic switcher + search + settings,
|
||||
@@ -157,6 +158,8 @@ fun ChatScreen(controller: IrisController) {
|
||||
val toolDetail by controller.toolDetail.collectAsState()
|
||||
val threadsEnabled by controller.threadsEnabled.collectAsState()
|
||||
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
||||
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
||||
|
||||
val channels by controller.channels.channels.collectAsState()
|
||||
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
|
||||
@@ -506,6 +509,8 @@ fun ChatScreen(controller: IrisController) {
|
||||
}
|
||||
}
|
||||
},
|
||||
runtimeFooterEnabled = runtimeFooterEnabled,
|
||||
runtimeFooterFields = runtimeFooterFields,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1776,8 +1781,11 @@ private fun InspectorPane(
|
||||
InfoRow("Threads", threads.size.toString())
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
|
||||
InfoRow("Model", lastAssistant?.model ?: "—")
|
||||
InfoRow("Tokens", lastAssistant?.tokens?.toString() ?: "—")
|
||||
InfoRow("Model", lastAssistant?.runtime?.model ?: "—")
|
||||
InfoRow("Context", lastAssistant?.runtime?.contextPct?.let { "$it%" } ?: "—")
|
||||
InfoRow("Workdir", lastAssistant?.runtime?.cwd ?: "—")
|
||||
InfoRow("Latency", lastAssistant?.runtime?.latency?.let { formatLatency(it) } ?: "—")
|
||||
InfoRow("Cost", lastAssistant?.runtime?.cost?.let { formatCost(it) } ?: "—")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2211,6 +2219,8 @@ private fun MessageBubble(
|
||||
selected: Boolean = false,
|
||||
onToggleSelect: () -> Unit = {},
|
||||
onLongPress: () -> Unit = {},
|
||||
runtimeFooterEnabled: Boolean = false,
|
||||
runtimeFooterFields: List<String> = emptyList(),
|
||||
) {
|
||||
val isUser = msg.role == ROLE_USER
|
||||
val isCommentary = msg.isCommentary
|
||||
@@ -2345,28 +2355,34 @@ private fun MessageBubble(
|
||||
}
|
||||
}
|
||||
if (!isUser) {
|
||||
// Model / token footer (final assistant answers only).
|
||||
if (!isCommentary && !msg.streaming &&
|
||||
(msg.model != null || msg.tokens != null)
|
||||
) {
|
||||
// Runtime-metadata footer + timestamp on ONE line (footer left,
|
||||
// time right) — Telegram-style. The app controls whether/what
|
||||
// the footer shows via Settings → Runtime footer.
|
||||
val footerText =
|
||||
if (!isCommentary && !msg.streaming && runtimeFooterEnabled) {
|
||||
buildRuntimeFooterText(msg, runtimeFooterFields)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
if (footerText.isNotEmpty() || time.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
buildString {
|
||||
msg.model?.let { append(it) }
|
||||
if (msg.model != null && msg.tokens != null) append(" · ")
|
||||
msg.tokens?.let { append("$it tok") }
|
||||
},
|
||||
color = textColor.copy(alpha = 0.4f),
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
// M7: timestamp below the footer (reference shows "12:41").
|
||||
if (time.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.5f))
|
||||
if (footerText.isNotEmpty()) {
|
||||
Text(
|
||||
footerText,
|
||||
color = textColor.copy(alpha = 0.4f),
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
if (time.isNotEmpty()) {
|
||||
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2378,6 +2394,39 @@ private fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the runtime-footer text from the message's runtime metadata, showing
|
||||
* only the enabled [fields] (in order) that have data. Empty when none. */
|
||||
private fun buildRuntimeFooterText(
|
||||
msg: MessageItem,
|
||||
fields: List<String>,
|
||||
): String {
|
||||
val runtime = msg.runtime ?: return ""
|
||||
val parts = mutableListOf<String>()
|
||||
for (field in fields) {
|
||||
when (field) {
|
||||
"model" -> runtime.model?.let { parts.add(it) }
|
||||
"context_pct" -> runtime.contextPct?.let { parts.add("$it%") }
|
||||
"cwd" -> runtime.cwd?.let { parts.add(it) }
|
||||
"latency" -> runtime.latency?.let { parts.add(formatLatency(it)) }
|
||||
"cost" -> runtime.cost?.let { parts.add(formatCost(it)) }
|
||||
}
|
||||
}
|
||||
return parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
/** Humanize a turn duration: `<1s`, `22s`, `1m05s` (matches hermes' footer). */
|
||||
private fun formatLatency(seconds: Double): String {
|
||||
if (seconds < 1) return "<1s"
|
||||
val total = seconds.roundToInt()
|
||||
if (total < 60) return "${total}s"
|
||||
val m = total / 60
|
||||
val s = total % 60
|
||||
return "${m}m${s.toString().padStart(2, '0')}s"
|
||||
}
|
||||
|
||||
/** Format a cost in USD: sub-cent at 4 decimals, else 2. */
|
||||
private fun formatCost(cost: Double): String = if (cost < 0.01) "$%.4f".format(cost) else "$%.2f".format(cost)
|
||||
|
||||
/** Circular selection indicator shown beside a bubble in selection mode. */
|
||||
@Composable
|
||||
private fun SelectionCheck(selected: Boolean) {
|
||||
|
||||
@@ -72,6 +72,8 @@ fun SettingsScreen(
|
||||
val threadsEnabled by controller.threadsEnabled.collectAsState()
|
||||
val streamingEnabled by controller.streamingEnabled.collectAsState()
|
||||
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
||||
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
||||
val toolDetail by controller.toolDetail.collectAsState()
|
||||
val fontSizeScale by controller.fontSizeScale.collectAsState()
|
||||
val theme = LocalUserTheme.current
|
||||
@@ -159,6 +161,39 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("📊 Runtime footer", fontSize = 14.sp)
|
||||
Text(
|
||||
"Show model, context, workdir, latency and cost under replies",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = runtimeFooterEnabled,
|
||||
onCheckedChange = { controller.toggleRuntimeFooter() },
|
||||
)
|
||||
}
|
||||
if (runtimeFooterEnabled) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Fields to show", fontSize = 12.sp, color = IrisColors.textDim)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
IrisController.RUNTIME_FIELD_KEYS.forEach { field ->
|
||||
TopicChip(
|
||||
label = runtimeFieldLabel(field),
|
||||
selected = field in runtimeFooterFields,
|
||||
onClick = { controller.toggleRuntimeField(field) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Appearance",
|
||||
@@ -620,3 +655,14 @@ private fun argbToHsv(argb: Int): FloatArray {
|
||||
|
||||
/** File name from a path (no java.io.File in common code). */
|
||||
private fun fileNameOf(path: String): String = path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
|
||||
|
||||
/** Human label for a runtime-footer field key (Settings → Runtime footer). */
|
||||
private fun runtimeFieldLabel(field: String): String =
|
||||
when (field) {
|
||||
"model" -> "Model"
|
||||
"context_pct" -> "Context %"
|
||||
"cwd" -> "Workdir"
|
||||
"latency" -> "Latency"
|
||||
"cost" -> "Cost"
|
||||
else -> field
|
||||
}
|
||||
@@ -48,6 +48,8 @@ class DesktopSecureStore : SecureStore {
|
||||
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
|
||||
val backgroundImagePath: String = Backdrop.DEFAULT.path,
|
||||
val fontSizeScale: Float = 1.0f,
|
||||
val runtimeFooterEnabled: Boolean = false,
|
||||
val runtimeFooterFields: String = "",
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -231,6 +233,20 @@ class DesktopSecureStore : SecureStore {
|
||||
save(d.copy(fontSizeScale = value))
|
||||
}
|
||||
|
||||
override var runtimeFooterEnabled: Boolean
|
||||
get() = load().runtimeFooterEnabled
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(runtimeFooterEnabled = value))
|
||||
}
|
||||
|
||||
override var runtimeFooterFields: String
|
||||
get() = load().runtimeFooterFields
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(runtimeFooterFields = value))
|
||||
}
|
||||
|
||||
override fun savePairing(
|
||||
url: String,
|
||||
token: String,
|
||||
|
||||
Reference in new issue
Block a user