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.EnvVarRevealRequest
|
||||||
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
|
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
|
||||||
import com.m57.hermescontrol.data.model.EnvVarUpdate
|
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.HookResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
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.MoaConfigResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
|
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
|
||||||
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
|
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.ModelOptionsResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelsAnalyticsResponse
|
import com.m57.hermescontrol.data.model.ModelsAnalyticsResponse
|
||||||
import com.m57.hermescontrol.data.model.OAuthCancelResponse
|
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.RecentUnlock
|
||||||
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
|
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
|
||||||
import com.m57.hermescontrol.data.model.ScanStatus
|
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.SessionListResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionPromptResponse
|
import com.m57.hermescontrol.data.model.SessionPromptResponse
|
||||||
@@ -123,6 +126,9 @@ interface HermesApiService {
|
|||||||
@GET("api/status")
|
@GET("api/status")
|
||||||
suspend fun getStatus(): Response<StatusResponse>
|
suspend fun getStatus(): Response<StatusResponse>
|
||||||
|
|
||||||
|
@GET("api/health")
|
||||||
|
suspend fun getHealth(): Response<HealthStatus>
|
||||||
|
|
||||||
@GET("api/sessions")
|
@GET("api/sessions")
|
||||||
suspend fun getSessions(
|
suspend fun getSessions(
|
||||||
@Query("limit") limit: Int = 20,
|
@Query("limit") limit: Int = 20,
|
||||||
@@ -181,6 +187,17 @@ interface HermesApiService {
|
|||||||
@Path("id", encoded = true) sessionId: String,
|
@Path("id", encoded = true) sessionId: String,
|
||||||
): Response<SessionPromptResponse>
|
): 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")
|
@GET("api/system/stats")
|
||||||
suspend fun getSystemStats(): Response<SystemStatsResponse>
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.ChatLoadingOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatMessageList
|
import com.m57.hermescontrol.ui.chat.components.ChatMessageList
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
|
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.ReactionHeartsOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
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
|
* this entry point handles only state hoisting, scaffold wiring, and the
|
||||||
* remembered launchers that need to be activity-scoped.
|
* remembered launchers that need to be activity-scoped.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -125,12 +128,14 @@ fun ChatScreen(
|
|||||||
val scrollScope = rememberCoroutineScope()
|
val scrollScope = rememberCoroutineScope()
|
||||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||||
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
||||||
|
var showContextSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Periodic session sync while connected.
|
// Periodic session sync while connected.
|
||||||
LaunchedEffect(state.currentSessionId, state.connectionStatus) {
|
LaunchedEffect(state.currentSessionId, state.connectionStatus) {
|
||||||
while (state.currentSessionId != null && state.connectionStatus == ConnectionStatus.CONNECTED) {
|
while (state.currentSessionId != null && state.connectionStatus == ConnectionStatus.CONNECTED) {
|
||||||
delay(SESSION_SYNC_INTERVAL_MS)
|
delay(SESSION_SYNC_INTERVAL_MS)
|
||||||
viewModel.syncCurrentSession()
|
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(
|
ChatInputBar(
|
||||||
inputFieldValue = inputFieldValue,
|
inputFieldValue = inputFieldValue,
|
||||||
onInputChange = { inputFieldValue = it },
|
onInputChange = { inputFieldValue = it },
|
||||||
@@ -610,5 +629,13 @@ fun ChatScreen(
|
|||||||
onDismiss = { viewModel.closeModelPicker() },
|
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,
|
val currentSessionModel: String? = null,
|
||||||
// Reasoning effort level for the current session
|
// Reasoning effort level for the current session
|
||||||
val reasoningLevel: String? = null,
|
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
|
// Attachment state
|
||||||
val pendingAttachments: List<Attachment> = emptyList(),
|
val pendingAttachments: List<Attachment> = emptyList(),
|
||||||
// Reaction animation — set when a reaction WS event arrives, auto-clears
|
// Reaction animation — set when a reaction WS event arrives, auto-clears
|
||||||
@@ -142,6 +150,21 @@ data class SecretPromptUi(
|
|||||||
val sessionId: String?,
|
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(
|
class ChatViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val startCleanup: Boolean,
|
private val startCleanup: Boolean,
|
||||||
@@ -386,6 +409,12 @@ class ChatViewModel(
|
|||||||
is ReducerEffect.RefreshSessions -> {
|
is ReducerEffect.RefreshSessions -> {
|
||||||
loadSessions()
|
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",
|
currentSessionModel = "$provider/$model",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Model switch changes the context-window denominator — refetch it.
|
||||||
|
fetchContextUsage()
|
||||||
handleSlashCommand("/model $model --provider $provider --session")
|
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 {
|
private suspend fun fetchServerMessageCount(sessionId: String): Int {
|
||||||
val known =
|
val known =
|
||||||
_uiState.value.sessions
|
_uiState.value.sessions
|
||||||
|
|||||||
@@ -252,6 +252,9 @@ object ChatWsEventReducer {
|
|||||||
effects.add(ReducerEffect.PersistMessage(msg, sid))
|
effects.add(ReducerEffect.PersistMessage(msg, sid))
|
||||||
}
|
}
|
||||||
effects.add(ReducerEffect.RefreshSessions)
|
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(
|
return ReducerResult(
|
||||||
state =
|
state =
|
||||||
state.copy(
|
state.copy(
|
||||||
@@ -664,4 +667,6 @@ sealed class ReducerEffect {
|
|||||||
data object LoadSessions : ReducerEffect()
|
data object LoadSessions : ReducerEffect()
|
||||||
|
|
||||||
data object RefreshSessions : 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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.ConnectionProfile
|
||||||
import com.m57.hermescontrol.data.config.resolveBaseUrl
|
import com.m57.hermescontrol.data.config.resolveBaseUrl
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||||
import com.m57.hermescontrol.data.remote.NetworkError
|
import com.m57.hermescontrol.data.remote.NetworkError
|
||||||
@@ -33,6 +34,7 @@ data class ConnectUiState(
|
|||||||
val saveProfile: Boolean = false,
|
val saveProfile: Boolean = false,
|
||||||
val profiles: List<ConnectionProfile> = emptyList(),
|
val profiles: List<ConnectionProfile> = emptyList(),
|
||||||
val selectedProfile: ConnectionProfile? = null,
|
val selectedProfile: ConnectionProfile? = null,
|
||||||
|
val health: HealthStatus? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class ConnectViewModel(
|
class ConnectViewModel(
|
||||||
@@ -60,6 +62,29 @@ class ConnectViewModel(
|
|||||||
profileName = selectedProfile?.name ?: "",
|
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) {
|
fun onProfileNameChange(value: String) {
|
||||||
|
|||||||
@@ -179,6 +179,10 @@
|
|||||||
<!-- Connect Screen -->
|
<!-- Connect Screen -->
|
||||||
<string name="connect_selected_profile">프로필: %1$s</string>
|
<string name="connect_selected_profile">프로필: %1$s</string>
|
||||||
<string name="connect_action_select_profile">저장된 프로필 선택</string>
|
<string name="connect_action_select_profile">저장된 프로필 선택</string>
|
||||||
|
<string name="connect_health_title">백엔드 상태</string>
|
||||||
|
<string name="connect_health_body">Hermes %1$s · %2$s</string>
|
||||||
|
<string name="connect_health_auth_gated">게이트(OAuth)</string>
|
||||||
|
<string name="connect_health_auth_token">토큰</string>
|
||||||
|
|
||||||
<!-- Connect Error Messages -->
|
<!-- Connect Error Messages -->
|
||||||
<string name="connect_error_token_required">토큰이 필요합니다</string>
|
<string name="connect_error_token_required">토큰이 필요합니다</string>
|
||||||
|
|||||||
@@ -207,6 +207,10 @@
|
|||||||
<!-- Connect Screen -->
|
<!-- Connect Screen -->
|
||||||
<string name="connect_selected_profile">Profile: %1$s</string>
|
<string name="connect_selected_profile">Profile: %1$s</string>
|
||||||
<string name="connect_action_select_profile">Select Saved Profile</string>
|
<string name="connect_action_select_profile">Select Saved Profile</string>
|
||||||
|
<string name="connect_health_title">Backend health</string>
|
||||||
|
<string name="connect_health_body">Hermes %1$s · %2$s</string>
|
||||||
|
<string name="connect_health_auth_gated">Gated (OAuth)</string>
|
||||||
|
<string name="connect_health_auth_token">Token</string>
|
||||||
|
|
||||||
<!-- Connect Error Messages -->
|
<!-- Connect Error Messages -->
|
||||||
<string name="connect_error_token_required">Token is required</string>
|
<string name="connect_error_token_required">Token is required</string>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class ChatViewModelTest {
|
|||||||
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
|
|
||||||
every { AuthManager.getToken() } returns "test-token"
|
every { AuthManager.getToken() } returns "test-token"
|
||||||
|
every { AuthManager.getSelectedProfileId() } returns null
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
every { AuthManager.isAutoReconnect() } returns false
|
every { AuthManager.isAutoReconnect() } returns false
|
||||||
@@ -1742,7 +1743,10 @@ class ChatViewModelTest {
|
|||||||
"hasOlderMessages must be false when initial page is empty",
|
"hasOlderMessages must be false when initial page is empty",
|
||||||
viewModel.uiState.value.hasOlderMessages,
|
viewModel.uiState.value.hasOlderMessages,
|
||||||
)
|
)
|
||||||
assertTrue(viewModel.uiState.value.messages.isEmpty())
|
assertTrue(
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
assertFalse(viewModel.uiState.value.isLoading)
|
assertFalse(viewModel.uiState.value.isLoading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the token-compaction math in [ContextUsageChip] — the only
|
||||||
|
* non-trivial pure logic in the context meter. Formats must stay compact so
|
||||||
|
* the chip fits above the composer on a narrow phone screen.
|
||||||
|
*/
|
||||||
|
class ContextUsageChipTest {
|
||||||
|
@Test
|
||||||
|
fun formatTokens_subThousand_returnsRawCount() {
|
||||||
|
assertEquals("950", formatTokens(950))
|
||||||
|
assertEquals("0", formatTokens(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun formatTokens_thousands_dropsTrailingPointZero() {
|
||||||
|
// 262_144 -> "262k" (no ".0"), not "262.1k".
|
||||||
|
assertEquals("262k", formatTokens(262_144))
|
||||||
|
// 12_300 -> "12.3k" (one decimal preserved).
|
||||||
|
assertEquals("12.3k", formatTokens(12_300))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun formatTokens_millions_usesMSuffix() {
|
||||||
|
assertEquals("1M", formatTokens(1_000_000))
|
||||||
|
assertEquals("2M", formatTokens(2_048_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun formatTokens_exactThousandBoundary_roundsDownCleanly() {
|
||||||
|
// 1_000 -> "1k" not "1.0k".
|
||||||
|
assertEquals("1k", formatTokens(1_000))
|
||||||
|
assertEquals("100k", formatTokens(100_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user