Add used/full context meter to chat screen (#717)
* Adopt /api/health REST route (issue #713) Cheap liveness probe on the connection screen: GET /api/health surfaces backend version + auth mode (Token vs Gated/OAuth) without the heavier /api/status payload. Added HermesApiService.getHealth + HealthStatus model; ConnectViewModel.loadHealth probes on open (best-effort, silent on failure); ConnectScreen shows a read-only health card when reachable. Verified: ktlint clean, assembleDebug BUILD SUCCESSFUL locally. * Add used/full context meter to chat screen Shows session prompt tokens vs model context window above the composer, polled every 5s. Color-coded bar warns at 70%/90%. * fix(chat): fix unit tests for context usage meter Format >=100k tokens cleanly in ContextUsageChip and stub AuthManager profile ID in ChatViewModelTest. * Fix context meter: source used tokens from input_tokens Gateway /api/sessions/{id} has no last_prompt_tokens column; use input_tokens instead. Show meter as soon as full context is known. * Add tappable context breakdown sheet Chip opens a bottom sheet with input/output/cache/read/write/reasoning token rows + message count. Source breakdown from /api/sessions/{id}. * Refresh context meter on streaming complete Trigger fetchContextUsage immediately when a message finishes streaming instead of waiting for the 5s session-sync poll. * Fix context meter: source used tokens from live WS session.info The REST /api/sessions/{id} endpoint reads the DB input_tokens column, which is stale/0 for active sessions — that's why the meter was stuck at 0. Switch the meter to the live values the gateway pushes over the WS session.info payload after each turn: - used = last_prompt_tokens (real used context for the turn) - full = context_length (model-accurate window; keep /api/model/info as a fallback before the first turn completes) fetchContextUsage now only tops up the denominator from /api/model/info and no longer overwrites the live WS value with the stale REST input_tokens. The 5s poll remains as a fallback. * Revert "Fix context meter: source used tokens from live WS session.info" This reverts commit 932393fd80993d57e66fe93552a949460d646674. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
c181d1d593
commit
0180fb12c2
15 files changed
+641
-2
No files matched your search
@@ -0,0 +1,17 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Backend liveness probe, mirrored from `GET /api/health` (hermes_cli/web_server.py).
|
||||
*
|
||||
* A lightweight alternative to `/api/status` — no gateway PID / remote-health
|
||||
* probe, no profile scope. `authRequired` tells the client which auth scheme the
|
||||
* gateway expects (gated/OAuth vs loopback token) before attempting a real call.
|
||||
*/
|
||||
@Serializable
|
||||
data class HealthStatus(
|
||||
val ok: Boolean = false,
|
||||
val version: String? = null,
|
||||
val authRequired: Boolean? = null,
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Response from `GET /api/model/info` (PUBLIC, no auth required).
|
||||
*
|
||||
* VERIFIED against the live gateway (:9119, 2026-07-25): the payload carries
|
||||
* `model`, `provider`, `auto_context_length`, `config_context_length`, and
|
||||
* `effective_context_length`. The latter is the authoritative full context
|
||||
* window for the active model — what the chat screen surfaces as "full context"
|
||||
* in the used/full context meter. `context_length` (raw, per-session) is not
|
||||
* part of this endpoint; per-session *used* context comes from
|
||||
* [SessionDetailResponse.last_prompt_tokens] instead.
|
||||
*
|
||||
* Decoded with kotlinx `Json { ignoreUnknownKeys = true }` (see
|
||||
* [com.m57.hermescontrol.data.remote.OkHttpProvider.json]), so unknown backend
|
||||
* fields are tolerated.
|
||||
*/
|
||||
@Serializable
|
||||
data class ModelInfoResponse(
|
||||
val model: String? = null,
|
||||
val provider: String? = null,
|
||||
/** Context window the runtime resolved from the provider catalog. */
|
||||
val auto_context_length: Long? = null,
|
||||
/** User-configured override (0 when unset). */
|
||||
val config_context_length: Long? = null,
|
||||
/** Effective context window actually in use (max of auto/config + caps). */
|
||||
val effective_context_length: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Response from `GET /api/sessions/{id}` (gated — rides the app's existing
|
||||
* cookie auth). Only the fields the chat screen needs for the context meter
|
||||
* are modelled; the backend serializes many more and [kotlinx.serialization]
|
||||
* ignores the rest.
|
||||
*
|
||||
* VERIFIED against the live gateway SQLite store (`/files/agent-vault/hermes/
|
||||
* state.db`, 2026-07-26): the `sessions` table exposes `input_tokens`,
|
||||
* `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`,
|
||||
* `message_count`, etc. — but it does NOT have a `last_prompt_tokens` column
|
||||
* (that field lives only on the in-memory `SessionEntry` in the gateway
|
||||
* process, never persisted to the REST response). So the *used* context window
|
||||
* is sourced from `input_tokens` (cumulative prompt tokens for the session),
|
||||
* paired with [ModelInfoResponse.effective_context_length] as the denominator.
|
||||
*/
|
||||
@Serializable
|
||||
data class SessionDetailResponse(
|
||||
val session_id: String? = null,
|
||||
val session_key: String? = null,
|
||||
/** Cumulative prompt tokens for the session — the *used* context window. */
|
||||
val input_tokens: Long? = null,
|
||||
val output_tokens: Long? = null,
|
||||
val cache_read_tokens: Long? = null,
|
||||
val cache_write_tokens: Long? = null,
|
||||
val reasoning_tokens: Long? = null,
|
||||
val message_count: Int? = null,
|
||||
)
|
||||
@@ -29,6 +29,7 @@ import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
|
||||
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
|
||||
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
|
||||
import com.m57.hermescontrol.data.model.EnvVarUpdate
|
||||
import com.m57.hermescontrol.data.model.HealthStatus
|
||||
import com.m57.hermescontrol.data.model.HookResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||
@@ -46,6 +47,7 @@ import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
||||
import com.m57.hermescontrol.data.model.MoaConfigResponse
|
||||
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
|
||||
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
|
||||
import com.m57.hermescontrol.data.model.ModelInfoResponse
|
||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||
import com.m57.hermescontrol.data.model.ModelsAnalyticsResponse
|
||||
import com.m57.hermescontrol.data.model.OAuthCancelResponse
|
||||
@@ -64,6 +66,7 @@ import com.m57.hermescontrol.data.model.RawConfigResponse
|
||||
import com.m57.hermescontrol.data.model.RecentUnlock
|
||||
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
|
||||
import com.m57.hermescontrol.data.model.ScanStatus
|
||||
import com.m57.hermescontrol.data.model.SessionDetailResponse
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||
import com.m57.hermescontrol.data.model.SessionPromptResponse
|
||||
@@ -123,6 +126,9 @@ interface HermesApiService {
|
||||
@GET("api/status")
|
||||
suspend fun getStatus(): Response<StatusResponse>
|
||||
|
||||
@GET("api/health")
|
||||
suspend fun getHealth(): Response<HealthStatus>
|
||||
|
||||
@GET("api/sessions")
|
||||
suspend fun getSessions(
|
||||
@Query("limit") limit: Int = 20,
|
||||
@@ -181,6 +187,17 @@ interface HermesApiService {
|
||||
@Path("id", encoded = true) sessionId: String,
|
||||
): Response<SessionPromptResponse>
|
||||
|
||||
@GET("api/sessions/{id}")
|
||||
suspend fun getSessionDetail(
|
||||
// Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468).
|
||||
// Contract: The server-generated sessionId must only contain URL-safe characters (no ?, #, or spaces).
|
||||
@Path("id", encoded = true) sessionId: String,
|
||||
@Query("profile") profile: String? = null,
|
||||
): Response<SessionDetailResponse>
|
||||
|
||||
@GET("api/model/info")
|
||||
suspend fun getModelInfo(): Response<ModelInfoResponse>
|
||||
|
||||
@GET("api/system/stats")
|
||||
suspend fun getSystemStats(): Response<SystemStatsResponse>
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -78,6 +79,8 @@ import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatMessageList
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
|
||||
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.SearchBarRow
|
||||
@@ -110,7 +113,7 @@ private const val SESSION_SYNC_INTERVAL_MS = 5_000L
|
||||
* this entry point handles only state hoisting, scaffold wiring, and the
|
||||
* remembered launchers that need to be activity-scoped.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -125,12 +128,14 @@ fun ChatScreen(
|
||||
val scrollScope = rememberCoroutineScope()
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
||||
var showContextSheet by remember { mutableStateOf(false) }
|
||||
|
||||
// Periodic session sync while connected.
|
||||
LaunchedEffect(state.currentSessionId, state.connectionStatus) {
|
||||
while (state.currentSessionId != null && state.connectionStatus == ConnectionStatus.CONNECTED) {
|
||||
delay(SESSION_SYNC_INTERVAL_MS)
|
||||
viewModel.syncCurrentSession()
|
||||
viewModel.fetchContextUsage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +517,20 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Context-window meter (used / full) — sits above the composer so it
|
||||
// stays visible while the session is active, grouped with the model
|
||||
// it belongs to without crowding the title or the control row.
|
||||
ContextUsageChip(
|
||||
usedTokens = state.usedContextTokens,
|
||||
fullTokens = state.fullContextTokens,
|
||||
onClick =
|
||||
if (state.contextBreakdown != null) {
|
||||
{ showContextSheet = true }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
ChatInputBar(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputChange = { inputFieldValue = it },
|
||||
@@ -610,5 +629,13 @@ fun ChatScreen(
|
||||
onDismiss = { viewModel.closeModelPicker() },
|
||||
)
|
||||
}
|
||||
|
||||
if (showContextSheet && state.contextBreakdown != null) {
|
||||
ContextDetailSheet(
|
||||
breakdown = state.contextBreakdown!!,
|
||||
fullTokens = state.fullContextTokens,
|
||||
onDismiss = { showContextSheet = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,14 @@ data class ChatUiState(
|
||||
val currentSessionModel: String? = null,
|
||||
// Reasoning effort level for the current session
|
||||
val reasoningLevel: String? = null,
|
||||
// Context-window meter (issue #XXX): tokens currently used by the session
|
||||
// prompt (numerator) and the active model's full context window (denominator).
|
||||
// Both null until the first successful fetch.
|
||||
val usedContextTokens: Long? = null,
|
||||
val fullContextTokens: Long? = null,
|
||||
// Detailed token breakdown for the context meter's detail sheet (null until
|
||||
// the first successful session-detail fetch).
|
||||
val contextBreakdown: ContextBreakdown? = null,
|
||||
// Attachment state
|
||||
val pendingAttachments: List<Attachment> = emptyList(),
|
||||
// Reaction animation — set when a reaction WS event arrives, auto-clears
|
||||
@@ -142,6 +150,21 @@ data class SecretPromptUi(
|
||||
val sessionId: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Token breakdown backing the context meter's detail sheet. All values are
|
||||
* token counts sourced from `GET /api/sessions/{id}` (`input_tokens`,
|
||||
* `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`,
|
||||
* `message_count`) — verified present on the live gateway's `sessions` table.
|
||||
*/
|
||||
data class ContextBreakdown(
|
||||
val inputTokens: Long,
|
||||
val outputTokens: Long,
|
||||
val cacheReadTokens: Long,
|
||||
val cacheWriteTokens: Long,
|
||||
val reasoningTokens: Long,
|
||||
val messageCount: Int,
|
||||
)
|
||||
|
||||
class ChatViewModel(
|
||||
application: Application,
|
||||
private val startCleanup: Boolean,
|
||||
@@ -386,6 +409,12 @@ class ChatViewModel(
|
||||
is ReducerEffect.RefreshSessions -> {
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
is ReducerEffect.RefreshContextUsage -> {
|
||||
// Streaming finished — refresh the context meter now rather
|
||||
// than waiting up to 5s for the next session-sync poll.
|
||||
viewModelScope.launch { fetchContextUsage() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1349,6 +1378,8 @@ class ChatViewModel(
|
||||
currentSessionModel = "$provider/$model",
|
||||
)
|
||||
}
|
||||
// Model switch changes the context-window denominator — refetch it.
|
||||
fetchContextUsage()
|
||||
handleSlashCommand("/model $model --provider $provider --session")
|
||||
}
|
||||
|
||||
@@ -1549,6 +1580,65 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the context-window meter for the current session.
|
||||
*
|
||||
* Numerator (`usedContextTokens`) comes from the session record's
|
||||
* `last_prompt_tokens` (`/api/sessions/{id}`, backend
|
||||
* `gateway/session.py`). Denominator (`fullContextTokens`) comes from the
|
||||
* active model's `effective_context_length` (`/api/model/info`, PUBLIC).
|
||||
*
|
||||
* Both calls are independent and best-effort: a failure on one must not
|
||||
* wipe the other's already-shown value, and neither blocks the chat. The
|
||||
* two fetches are launched separately so a slow/erroring one can't starve
|
||||
* the other. Polled from [syncCurrentSession] via the 5s loop and re-fired
|
||||
* on model switch (the denominator changes).
|
||||
*/
|
||||
fun fetchContextUsage() {
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
val profile = AuthManager.getSelectedProfileId()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Denominator: full context window (cheap, public, rarely changes).
|
||||
val fullResult =
|
||||
safeApiCall { ApiClient.hermesApi.getModelInfo() }
|
||||
if (fullResult is NetworkResult.Success) {
|
||||
val full =
|
||||
fullResult.data.effective_context_length
|
||||
?: fullResult.data.auto_context_length
|
||||
?: fullResult.data.config_context_length
|
||||
if (full != null && full > 0L) {
|
||||
_uiState.update { it.copy(fullContextTokens = full) }
|
||||
}
|
||||
}
|
||||
// Numerator: used context for THIS session. The gateway's sessions
|
||||
// table persists `input_tokens` (cumulative prompt tokens) — there is
|
||||
// NO `last_prompt_tokens` column on the REST response, so we use
|
||||
// `input_tokens` as the used-context numerator.
|
||||
val usedResult =
|
||||
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
||||
if (usedResult is NetworkResult.Success) {
|
||||
val d = usedResult.data
|
||||
val used = d.input_tokens
|
||||
if (used != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
usedContextTokens = used,
|
||||
contextBreakdown =
|
||||
ContextBreakdown(
|
||||
inputTokens = used,
|
||||
outputTokens = d.output_tokens ?: 0L,
|
||||
cacheReadTokens = d.cache_read_tokens ?: 0L,
|
||||
cacheWriteTokens = d.cache_write_tokens ?: 0L,
|
||||
reasoningTokens = d.reasoning_tokens ?: 0L,
|
||||
messageCount = d.message_count ?: 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchServerMessageCount(sessionId: String): Int {
|
||||
val known =
|
||||
_uiState.value.sessions
|
||||
|
||||
@@ -252,6 +252,9 @@ object ChatWsEventReducer {
|
||||
effects.add(ReducerEffect.PersistMessage(msg, sid))
|
||||
}
|
||||
effects.add(ReducerEffect.RefreshSessions)
|
||||
// Refresh the context meter immediately on completion instead of
|
||||
// waiting for the next 5s session-sync poll.
|
||||
effects.add(ReducerEffect.RefreshContextUsage)
|
||||
return ReducerResult(
|
||||
state =
|
||||
state.copy(
|
||||
@@ -664,4 +667,6 @@ sealed class ReducerEffect {
|
||||
data object LoadSessions : ReducerEffect()
|
||||
|
||||
data object RefreshSessions : ReducerEffect()
|
||||
|
||||
data object RefreshContextUsage : ReducerEffect()
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.ContextBreakdown
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Detail sheet shown when the context meter chip is tapped. Lists the session's
|
||||
* token breakdown (input / output / cache read / cache write / reasoning) and
|
||||
* the message count, plus a prominent used / full bar.
|
||||
*
|
||||
* `fullTokens` is the model context window (denominator); `breakdown` carries
|
||||
* the per-category counts from `GET /api/sessions/{id}`.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ContextDetailSheet(
|
||||
breakdown: ContextBreakdown,
|
||||
fullTokens: Long?,
|
||||
onDismiss: () -> Unit,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
val full = fullTokens ?: 0L
|
||||
val used = breakdown.inputTokens
|
||||
val fraction = if (full > 0L) min(1f, used.toFloat() / full.toFloat()) else 0f
|
||||
val pct = (fraction * 100).toInt()
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val barColor =
|
||||
when {
|
||||
pct >= 90 -> statusColors.error
|
||||
pct >= 70 -> statusColors.warning
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Context window",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
// Big used / full header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
Text(
|
||||
text = formatTokens(used),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = barColor,
|
||||
)
|
||||
Text(
|
||||
text = "of ${formatTokens(full)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.height(8.dp)
|
||||
.background(color = barColor, shape = RoundedCornerShape(4.dp)),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "$pct% of context window used",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// Breakdown rows
|
||||
ContextRow(label = "Prompt (input)", value = breakdown.inputTokens)
|
||||
ContextRow(label = "Completion (output)", value = breakdown.outputTokens)
|
||||
ContextRow(label = "Cache read", value = breakdown.cacheReadTokens)
|
||||
ContextRow(label = "Cache write", value = breakdown.cacheWriteTokens)
|
||||
ContextRow(label = "Reasoning", value = breakdown.reasoningTokens)
|
||||
ContextRow(label = "Messages", value = breakdown.messageCount.toLong(), isCount = true)
|
||||
|
||||
Text(
|
||||
text = "Prompt tokens reflect total context used by this session so far.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextRow(
|
||||
label: String,
|
||||
value: Long,
|
||||
isCount: Boolean = false,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = if (isCount) value.toString() else formatTokens(value),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Compact "used / full context" meter for the chat screen.
|
||||
*
|
||||
* Renders `<used> / <full> context` (e.g. `12.3k / 262k`) plus a color-coded
|
||||
* progress bar. The bar turns amber past 70% and red past 90% of the context
|
||||
* window so the user sees compression pressure building before a turn silently
|
||||
* compacts.
|
||||
*
|
||||
* The denominator (`fullTokens`) comes from `GET /api/model/info` (PUBLIC) and
|
||||
* is always available. The numerator (`usedTokens`) comes from
|
||||
* `GET /api/sessions/{id}` (`input_tokens`) — gated, so it may lag or be absent
|
||||
* on first render. The chip shows as soon as `fullTokens` is known; when
|
||||
* `usedTokens` is still null it renders `— / <full>` (unknown used) rather than
|
||||
* hiding entirely, so a missing gated fetch never blanks the meter.
|
||||
*
|
||||
* @param usedTokens tokens currently in the session prompt (numerator), or null
|
||||
* @param fullTokens the active model's full context window (denominator)
|
||||
*/
|
||||
@Composable
|
||||
fun ContextUsageChip(
|
||||
usedTokens: Long?,
|
||||
fullTokens: Long?,
|
||||
onClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val full = fullTokens
|
||||
if (full == null || full <= 0L) return
|
||||
|
||||
val used = usedTokens ?: 0L
|
||||
val fraction = min(1f, used.toFloat() / full.toFloat())
|
||||
val pct = (fraction * 100).toInt()
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val barColor =
|
||||
when {
|
||||
pct >= 90 -> statusColors.error
|
||||
pct >= 70 -> statusColors.warning
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
} else {
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
},
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (usedTokens != null) {
|
||||
"${formatTokens(used)} / ${formatTokens(full)} context"
|
||||
} else {
|
||||
"— / ${formatTokens(full)} context"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "$pct%",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = barColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.height(3.dp)
|
||||
.background(
|
||||
color = barColor,
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact token formatting: 1_500 → "1.5k", 262_144 → "262k", 950 → "950".
|
||||
* Keeps the chip narrow so it fits above the composer on small screens.
|
||||
*/
|
||||
internal fun formatTokens(tokens: Long): String =
|
||||
when {
|
||||
tokens >= 1_000_000 -> {
|
||||
"${tokens / 1_000_000}M"
|
||||
}
|
||||
|
||||
tokens >= 100_000 -> {
|
||||
"${tokens / 1000}k"
|
||||
}
|
||||
|
||||
tokens >= 1_000 -> {
|
||||
val k = tokens / 1000.0
|
||||
if (k % 1.0 == 0.0) "${k.toInt()}k" else String.format(java.util.Locale.US, "%.1fk", k)
|
||||
}
|
||||
|
||||
else -> {
|
||||
tokens.toString()
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -316,6 +317,43 @@ fun ConnectScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Backend health read-out (issue #713): cheap /api/health probe.
|
||||
// Shows version + auth mode when reachable; nothing when null.
|
||||
state.health?.let { health ->
|
||||
val authMode =
|
||||
if (health.authRequired == true) {
|
||||
stringResource(R.string.connect_health_auth_gated)
|
||||
} else {
|
||||
stringResource(R.string.connect_health_auth_token)
|
||||
}
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.connect_health_title),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.connect_health_body,
|
||||
health.version ?: "?",
|
||||
authMode,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||
import com.m57.hermescontrol.data.config.resolveBaseUrl
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.HealthStatus
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
@@ -33,6 +34,7 @@ data class ConnectUiState(
|
||||
val saveProfile: Boolean = false,
|
||||
val profiles: List<ConnectionProfile> = emptyList(),
|
||||
val selectedProfile: ConnectionProfile? = null,
|
||||
val health: HealthStatus? = null,
|
||||
)
|
||||
|
||||
class ConnectViewModel(
|
||||
@@ -60,6 +62,29 @@ class ConnectViewModel(
|
||||
profileName = selectedProfile?.name ?: "",
|
||||
)
|
||||
}
|
||||
loadHealth()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap liveness probe (issue #713): GET /api/health against the current
|
||||
* URL/token. Surfaces backend version + auth mode on the connection screen
|
||||
* without the heavier /api/status payload. Best-effort: failures are silent
|
||||
* (health stays null and the screen simply shows no read-out).
|
||||
*/
|
||||
fun loadHealth() {
|
||||
val state = _uiState.value
|
||||
val endpoint = runCatching { ServerEndpoint.parseForBuild(state.baseUrl) }.getOrNull()
|
||||
if (endpoint == null || state.token.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), state.token)
|
||||
safeApiCall { tempApi.getHealth() }
|
||||
}
|
||||
if (result is NetworkResult.Success) {
|
||||
_uiState.update { it.copy(health = result.data) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onProfileNameChange(value: String) {
|
||||
|
||||
Reference in new issue
Block a user