diff --git a/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/7.json b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/7.json new file mode 100644 index 00000000..3f6766e7 --- /dev/null +++ b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/7.json @@ -0,0 +1,110 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "3deb0f3f202de9316222a02a0b5ae80e", + "entities": [ + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_call_id` TEXT NOT NULL, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, `display_kind` TEXT, `token_count` INTEGER, `tps` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reasoningText", + "columnName": "reasoning_text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toolName", + "columnName": "tool_name", + "affinity": "TEXT" + }, + { + "fieldPath": "toolCallId", + "columnName": "tool_call_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toolStatus", + "columnName": "tool_status", + "affinity": "TEXT" + }, + { + "fieldPath": "isStreaming", + "columnName": "is_streaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayKind", + "columnName": "display_kind", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenCount", + "columnName": "token_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "tps", + "columnName": "tps", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_session_id_timestamp", + "unique": false, + "columnNames": [ + "session_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3deb0f3f202de9316222a02a0b5ae80e')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt index dcfebf18..548e45b5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt @@ -42,4 +42,8 @@ data class ChatMessageEntity( val isStreaming: Boolean = false, @ColumnInfo(name = "display_kind") val displayKind: String? = null, + @ColumnInfo(name = "token_count") + val tokenCount: Int? = null, + @ColumnInfo(name = "tps") + val tps: Double? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt index a7f40f68..2600557b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt @@ -35,6 +35,8 @@ fun ChatMessageEntity.toUiModel(): ChatMessage = else -> null }, displayKind = displayKind, + tokenCount = tokenCount, + tps = tps, ) fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = @@ -50,4 +52,6 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = toolStatus = toolStatus?.name, isStreaming = isStreaming, displayKind = displayKind, + tokenCount = tokenCount, + tps = tps, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt index 48b4b922..ab12e8b5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt @@ -12,7 +12,7 @@ import java.io.File @Database( entities = [ChatMessageEntity::class], - version = 6, + version = 7, exportSchema = true, ) abstract class HermesDatabase : RoomDatabase() { @@ -67,6 +67,18 @@ abstract class HermesDatabase : RoomDatabase() { } } + val MIGRATION_6_7: Migration = + object : Migration(6, 7) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "ALTER TABLE `chat_messages` ADD COLUMN `token_count` INTEGER", + ) + connection.execSQL( + "ALTER TABLE `chat_messages` ADD COLUMN `tps` REAL", + ) + } + } + fun get(context: Context): HermesDatabase = instance ?: synchronized(this) { // SQLCipher can't open plaintext SQLite databases — if an old @@ -97,6 +109,7 @@ abstract class HermesDatabase : RoomDatabase() { MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, + MIGRATION_6_7, ).fallbackToDestructiveMigration(false) .build() .also { instance = it } diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index 7f2ca663..c05002a3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable 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 @Serializable @@ -50,10 +51,18 @@ data class SessionMessage( val display_kind: String? = null, /** Display-only metadata for the marker (e.g. delegation result counts). */ val display_metadata: JsonElement? = null, + /** Token count recorded by the backend. */ + val token_count: JsonElement? = null, ) { val timestampText: String? get() = (timestamp as? JsonPrimitive)?.content + val tokenCount: Int? + get() { + val prim = token_count as? JsonPrimitive ?: return null + return prim.intOrNull ?: prim.content.toIntOrNull() + } + val timestampEpochMs: Long? get() { val prim = timestamp as? JsonPrimitive ?: return null diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt index 4250dc1f..9897320f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt @@ -20,6 +20,8 @@ data class UsageSnapshotResponse( val contextMax: Long? = null, /** Cumulative session total tokens. */ val totalTokens: Long? = null, + /** Rolling tokens-per-second generation speed. */ + val avgTps: Double? = null, ) /** @@ -38,5 +40,6 @@ fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? { 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(), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 4a19bd98..f2d680b3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -87,7 +87,7 @@ object EventParser { "message.complete" -> { val text = payload?.get("text") as? String ?: "" val reasoning = payload?.get("reasoning") as? String - WsEvent.MessageComplete(text, sessionId, reasoning) + WsEvent.MessageComplete(text, sessionId, reasoning, rawPayload = payload) } "message.done" -> { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 21d61b6a..3282eef9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -61,6 +61,8 @@ sealed class WsEvent { val reasoning: String? = null, /** Stored session id captured before background disconnect clears the active mapping. */ val storedSessionId: String? = null, + /** Full raw payload map (including usage/avg_tps) emitted with message.complete. */ + val rawPayload: Map? = null, ) : WsEvent() data class MessageDone( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt index 95600291..fcada2ea 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt @@ -34,6 +34,8 @@ data class GroupChatMessage( val isSystem: Boolean = false, val isPass: Boolean = false, val toolCalls: List = emptyList(), + val tokenCount: Int? = null, + val tps: Double? = null, ) const val STOPPED_SYSTEM_TEXT = "Discussion stopped" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt index bc403a26..719adf32 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupMessageCard.kt @@ -49,6 +49,7 @@ import com.m57.hermescontrol.ui.bots.group.CAPPED_SYSTEM_TEXT import com.m57.hermescontrol.ui.bots.group.GroupChatMessage import com.m57.hermescontrol.ui.bots.group.STOPPED_SYSTEM_TEXT import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.TokenEstimator import com.m57.hermescontrol.ui.chat.formatTimestamp import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.util.BidiUtils @@ -195,6 +196,26 @@ fun GroupMessageCard( color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), style = MaterialTheme.typography.labelSmall, ) + val userTokens = + message.tokenCount ?: TokenEstimator.estimate(message.text).takeIf { it > 0 } + if (userTokens != null && userTokens > 0) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(userTokens), + ), + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } } } } @@ -304,6 +325,44 @@ fun GroupMessageCard( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), style = MaterialTheme.typography.labelSmall, ) + val botTokens = + message.tokenCount ?: TokenEstimator.estimate(message.text).takeIf { it > 0 } + if (botTokens != null && botTokens > 0) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(botTokens), + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } + if (message.tps != null && message.tps > 0.0) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tps, + TokenEstimator.formatTps(message.tps), + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 779788d2..6668826a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -257,6 +257,44 @@ fun ChatBubble( color = userBubbleTextColor.copy(alpha = 0.6f), style = MaterialTheme.typography.labelSmall, ) + if (message.tokenCount != null && message.tokenCount > 0) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = userBubbleTextColor.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(message.tokenCount), + ), + color = userBubbleTextColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.testTag("bubble_token_count"), + ) + } + if (message.tps != null && message.tps > 0.0) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = userBubbleTextColor.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tps, + TokenEstimator.formatTps(message.tps), + ), + color = userBubbleTextColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.testTag("bubble_tps"), + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 55b49a1f..b7afe3d6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -79,6 +79,10 @@ data class ChatMessage( * When present, rendered at the bottom beside the copy button. */ val finishTimestamp: Long? = null, + /** Token count used by this message (persisted to SQLite). */ + val tokenCount: Int? = null, + /** Tokens per second generation speed (persisted to SQLite). */ + val tps: Double? = null, ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index 82eeffd5..2ef7269b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -171,6 +171,7 @@ internal fun mapServerMessages( } } + val tokenCount = msg.tokenCount ?: TokenEstimator.estimate(finalContent).takeIf { it > 0 } mapped.add( ChatMessage( id = restId, @@ -182,6 +183,7 @@ internal fun mapServerMessages( timestamp = timestamp, isStreaming = false, displayKind = msg.display_kind, + tokenCount = tokenCount, ), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 3077478c..a671f7b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -146,6 +146,8 @@ data class ChatUiState( // until the first successful session.usage fetch) — drives the // "compressed ×N" badge on the context chip. val compressionCount: Int? = null, + /** Rolling output tokens/sec over the last ~10 calls. */ + val latestTps: Double? = null, // Attachment state val pendingAttachments: List = emptyList(), /** One-shot composer recovery after an attachment is rejected before send. */ @@ -1298,6 +1300,7 @@ class ChatViewModel( role = MessageRole.USER, content = text, attachments = if (attachments.isNotEmpty()) attachments else null, + tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 }, ) // Update UI immediately @@ -1342,6 +1345,7 @@ class ChatViewModel( role = MessageRole.USER, content = text, attachments = if (attachments.isNotEmpty()) attachments else null, + tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 }, ) // Upload attachments then submit prompt @@ -1624,7 +1628,12 @@ class ChatViewModel( val displayContent = if (result is SlashResult.QueuePrompt) result.displayContent else command - val userMsg = ChatMessage(role = MessageRole.USER, content = displayContent) + val userMsg = + ChatMessage( + role = MessageRole.USER, + content = displayContent, + tokenCount = TokenEstimator.estimate(displayContent).takeIf { it > 0 }, + ) val sessionId = _uiState.value.currentSessionId _uiState.update { it.copy(messages = it.messages + userMsg) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index d8db9c15..4c2ad8eb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -221,6 +221,8 @@ object ChatWsEventReducer { streamingState.streamingMessage.copy( isStreaming = false, finishTimestamp = System.currentTimeMillis(), + tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 }, + tps = state.latestTps?.takeIf { it > 0.0 }, ) orphan = finalized state.copy( @@ -379,17 +381,25 @@ object ChatWsEventReducer { streamingState = streamingState.copy(streamingMessage = null), ) } + val rawUsage = event.rawPayload?.get("usage") as? Map<*, *> + val tpsFromPayload = (rawUsage?.get("avg_tps") as? Number)?.toDouble() + val tps = (tpsFromPayload ?: state.latestTps)?.takeIf { it > 0.0 } + val tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 } val msg = streaming?.copy( content = text, isStreaming = false, reasoningText = reasoning, finishTimestamp = System.currentTimeMillis(), + tokenCount = tokenCount, + tps = tps, ) ?: ChatMessage( role = MessageRole.ASSISTANT, content = text, reasoningText = reasoning, finishTimestamp = System.currentTimeMillis(), + tokenCount = tokenCount, + tps = tps, ) val effects = mutableListOf() val sid = state.currentSessionId @@ -435,11 +445,14 @@ object ChatWsEventReducer { } else { streaming.reasoningText } + val tokenCount = TokenEstimator.estimate(streaming.content).takeIf { it > 0 } val msg = streaming.copy( isStreaming = false, reasoningText = reasoning, finishTimestamp = System.currentTimeMillis(), + tokenCount = streaming.tokenCount ?: tokenCount, + tps = streaming.tps ?: state.latestTps?.takeIf { it > 0.0 }, ) val effects = mutableListOf() val sid = state.currentSessionId @@ -504,6 +517,8 @@ object ChatWsEventReducer { isStreaming = false, reasoningText = reasoning, finishTimestamp = System.currentTimeMillis(), + tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 }, + tps = state.latestTps?.takeIf { it > 0.0 }, ) orphanToPersist = finalized state.copy( @@ -962,6 +977,9 @@ object ChatWsEventReducer { if (snapshot.contextMax != null && snapshot.contextMax > 0L) { updatedState = updatedState.copy(fullContextTokens = snapshot.contextMax) } + if (snapshot.avgTps != null && snapshot.avgTps > 0.0) { + updatedState = updatedState.copy(latestTps = snapshot.avgTps) + } return ReducerResult(state = updatedState, streamingState = streamingState) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/TokenEstimator.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/TokenEstimator.kt new file mode 100644 index 00000000..884edcc1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/TokenEstimator.kt @@ -0,0 +1,55 @@ +package com.m57.hermescontrol.ui.chat + +import java.util.Locale + +/** + * Token count estimation and formatting utilities. + * + * The estimation algorithm matches the backend's `estimate_tokens_rough` + * in `agent/model_metadata.py`: + * - CJK ideographs, Hangul, Kana count as ~1 token each. + * - Non-CJK characters count as ceil(UTF-8 bytes / 4). + */ +object TokenEstimator { + fun estimate(text: String?): Int { + if (text.isNullOrEmpty()) return 0 + var dense = 0 + var nonDenseByteCount = 0 + for (i in 0 until text.length) { + val ch = text[i] + if (isCjkDense(ch)) { + dense++ + } else { + val code = ch.code + nonDenseByteCount += + when { + code <= 0x7F -> 1 + code <= 0x7FF -> 2 + code <= 0xFFFF -> 3 + else -> 4 + } + } + } + return dense + ((nonDenseByteCount + 3) / 4) + } + + private fun isCjkDense(ch: Char): Boolean { + val code = ch.code + return (code in 0x4E00..0x9FFF) || + (code in 0x3400..0x4DBF) || + (code in 0xAC00..0xD7AF) || + (code in 0x1100..0x11FF) || + (code in 0x3040..0x309F) || + (code in 0x30A0..0x30FF) + } + + fun formatTokenCount(tokens: Int): String = + when { + tokens >= 1_000_000 -> String.format(Locale.US, "%.1fM", tokens / 1_000_000.0) + tokens >= 10_000 -> String.format(Locale.US, "%.1fk", tokens / 1_000.0) + tokens >= 1_000 -> String.format(Locale.US, "%,d", tokens) + else -> tokens.toString() + } + + fun formatTps(tps: Double): String = Math.round(tps).toString() +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 8acdba59..f34a0fa5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -40,6 +40,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.InlineAttachment import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.TokenEstimator import com.m57.hermescontrol.ui.chat.components.ReasoningCard import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -171,6 +172,44 @@ internal fun FullBleedAgentMessage( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.testTag("fullbleed_finish_time"), ) + if (message.tokenCount != null && message.tokenCount > 0) { + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(message.tokenCount), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.testTag("fullbleed_token_count"), + ) + } + if (message.tps != null && message.tps > 0.0) { + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tps, + TokenEstimator.formatTps(message.tps), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.testTag("fullbleed_tps"), + ) + } } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 1ba136d5..66e586fc 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -168,6 +168,8 @@ التمرير إلى الأسفل نسخ تعديل +%1$s رمز +%1$s رمز/ث إزالة المرفق تعذر التقاط الصورة الاتصال diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e48b33cc..074382d3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -165,6 +165,8 @@ 맨 아래로 복사 편집 + %1$s 토큰 + %1$s t/s 첨부 제거 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index fcbfd2ec..4f941cc8 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -168,6 +168,8 @@ 滚动到底部 复制 编辑 + %1$s tok + %1$s t/s 移除附件 拍照失败 连接 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ca033e7..c5872bb3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -186,6 +186,8 @@ Scroll to bottom Copy Edit + %1$s tok + %1$s t/s Remove attachment diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt index 1498858f..bf254f79 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt @@ -23,6 +23,8 @@ class ChatMessageMapperTest { toolName = "search", toolStatus = "RUNNING", isStreaming = true, + tokenCount = 42, + tps = 55.5, ) val ui = entity.toUiModel() @@ -35,6 +37,8 @@ class ChatMessageMapperTest { assertEquals("search", ui.toolName) assertEquals(ToolStatus.RUNNING, ui.toolStatus) assertTrue(ui.isStreaming) + assertEquals(42, ui.tokenCount) + assertEquals(55.5, ui.tps ?: 0.0, 0.001) } @Test @@ -49,6 +53,8 @@ class ChatMessageMapperTest { toolName = null, toolStatus = null, isStreaming = false, + tokenCount = 100, + tps = 24.8, ) val entity = ui.toEntity("session-b") @@ -62,6 +68,8 @@ class ChatMessageMapperTest { assertNull(entity.toolName) assertNull(entity.toolStatus) assertFalse(entity.isStreaming) + assertEquals(100, entity.tokenCount) + assertEquals(24.8, entity.tps ?: 0.0, 0.001) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt index 35453938..63a290fa 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt @@ -70,4 +70,19 @@ class HermesDatabaseMigrationsTest { ) } } + + @Test + fun migration6to7_executesTokenCountAndTpsColumnAdditionOnSQLiteConnection() = + runBlocking { + val connection = mockk(relaxed = true) + HermesDatabase.MIGRATION_6_7.migrate(connection) + verify(exactly = 1) { + connection.execSQL( + "ALTER TABLE `chat_messages` ADD COLUMN `token_count` INTEGER", + ) + connection.execSQL( + "ALTER TABLE `chat_messages` ADD COLUMN `tps` REAL", + ) + } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/TokenEstimatorTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/TokenEstimatorTest.kt new file mode 100644 index 00000000..eea5f418 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/TokenEstimatorTest.kt @@ -0,0 +1,43 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TokenEstimatorTest { + @Test + fun estimate_emptyOrNull_returnsZero() { + assertEquals(0, TokenEstimator.estimate(null)) + assertEquals(0, TokenEstimator.estimate("")) + } + + @Test + fun estimate_asciiText_usesByteDiv4() { + // "test" is 4 bytes -> (4 + 3) / 4 = 1 token + assertEquals(1, TokenEstimator.estimate("test")) + // 8 chars -> (8 + 3) / 4 = 2 tokens + assertEquals(2, TokenEstimator.estimate("testtest")) + } + + @Test + fun estimate_cjkText_countsDenseCharacters() { + // 3 CJK ideographs -> 3 tokens + assertEquals(3, TokenEstimator.estimate("你好世界".substring(0, 3))) + // 4 Korean Hangul -> 4 tokens + assertEquals(4, TokenEstimator.estimate("안녕하세요".substring(0, 4))) + } + + @Test + fun formatTokenCount_formatsCompactUnits() { + assertEquals("42", TokenEstimator.formatTokenCount(42)) + assertEquals("999", TokenEstimator.formatTokenCount(999)) + assertEquals("1,500", TokenEstimator.formatTokenCount(1500)) + assertEquals("12.5k", TokenEstimator.formatTokenCount(12500)) + assertEquals("1.2M", TokenEstimator.formatTokenCount(1200000)) + } + + @Test + fun formatTps_roundsProperly() { + assertEquals("42", TokenEstimator.formatTps(41.6)) + assertEquals("50", TokenEstimator.formatTps(50.2)) + } +}