fix(chat): keep context meter aligned after model swaps (#1103)

This commit is contained in:
M57 committed 2026-09-13 19:39:33 +03:00
1 parent 4246ae196d
commit 0a2c782139
8 files changed
+720 -127

No files matched your search

@@ -1,5 +1,11 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.ws.toAny
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Live context-window occupancy from the `session.context_breakdown` WS RPC —
* the same RPC the Hermes desktop app's status-bar meter uses.
@@ -30,20 +36,47 @@ data class ContextBreakdownResponse(
* Parse the decoded JSON-RPC result of `session.context_breakdown`.
*
* The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`),
* so every count is read through [Number]. Returns null when the payload is
* so every count is read through [Number]. Also supports raw [JsonObject] from
* [HermesWsClient.request]. Returns null when the payload is
* not a map (error/malformed response) — callers treat that as unknown and
* keep the last known meter values.
*/
fun parseContextBreakdown(result: Any?): ContextBreakdownResponse? {
val map = result as? Map<*, *> ?: return null
val rawMap =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
} ?: return null
fun num(key: String): Long? = (map[key] as? Number)?.toLong()
fun num(key: String): Long? =
when (val value = rawMap[key]) {
is Number -> value.toLong()
is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
is String -> value.toLongOrNull()
else -> null
}
fun intNum(key: String): Int? =
when (val value = rawMap[key]) {
is Number -> value.toInt()
is JsonPrimitive -> value.intOrNull
is String -> value.toIntOrNull()
else -> null
}
fun str(key: String): String? =
when (val value = rawMap[key]) {
is String -> value
is JsonPrimitive -> value.content
else -> value?.toString()
}
return ContextBreakdownResponse(
contextUsed = num("context_used"),
contextMax = num("context_max"),
contextPercent = (map["context_percent"] as? Number)?.toInt(),
contextPercent = intNum("context_percent"),
estimatedTotal = num("estimated_total"),
model = map["model"] as? String,
model = str("model"),
)
}
@@ -1,5 +1,12 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.ws.toAny
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Minimal view of the `session.usage` WS RPC result or `session.usage` push event —
* only the fields the context widget consumes today. The backend sends many more keys
@@ -29,17 +36,47 @@ data class UsageSnapshotResponse(
*
* Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }`
* (from push event payload). The WS event parser decodes JSON numbers as [Double]
* (`JsonRpcModels.toAny`), so every count is read through [Number].
* (`JsonRpcModels.toAny`), so every count is read through [Number]. Also supports raw [JsonObject].
* Returns null when the payload is not a map (error/malformed response) — callers keep the last known value.
*/
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
val map = result as? Map<*, *> ?: return null
val usageMap = (map["usage"] as? Map<*, *>) ?: map
val rawMap =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
} ?: return null
val usageMap = (rawMap["usage"] as? Map<*, *>) ?: rawMap
fun num(key: String): Long? =
when (val value = usageMap[key]) {
is Number -> value.toLong()
is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
is String -> value.toLongOrNull()
else -> null
}
fun intNum(key: String): Int? =
when (val value = usageMap[key]) {
is Number -> value.toInt()
is JsonPrimitive -> value.intOrNull
is String -> value.toIntOrNull()
else -> null
}
fun doubleNum(key: String): Double? =
when (val value = usageMap[key]) {
is Number -> value.toDouble()
is JsonPrimitive -> value.doubleOrNull ?: value.longOrNull?.toDouble()
is String -> value.toDoubleOrNull()
else -> null
}
return UsageSnapshotResponse(
compressions = (usageMap["compressions"] as? Number)?.toInt(),
contextUsed = (usageMap["context_used"] as? Number)?.toLong(),
contextMax = (usageMap["context_max"] as? Number)?.toLong(),
totalTokens = (usageMap["total"] as? Number)?.toLong(),
avgTps = (usageMap["avg_tps"] as? Number)?.toDouble(),
compressions = intNum("compressions"),
contextUsed = num("context_used"),
contextMax = num("context_max"),
totalTokens = num("total"),
avgTps = doubleNum("avg_tps"),
)
}