feat(chat): display token usage and tps in message footers (#1094)
This commit is contained in:
24 files changed
+448
-3
No files matched your search
@@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,4 +42,8 @@ data class ChatMessageEntity(
|
|||||||
val isStreaming: Boolean = false,
|
val isStreaming: Boolean = false,
|
||||||
@ColumnInfo(name = "display_kind")
|
@ColumnInfo(name = "display_kind")
|
||||||
val displayKind: String? = null,
|
val displayKind: String? = null,
|
||||||
|
@ColumnInfo(name = "token_count")
|
||||||
|
val tokenCount: Int? = null,
|
||||||
|
@ColumnInfo(name = "tps")
|
||||||
|
val tps: Double? = null,
|
||||||
)
|
)
|
||||||
@@ -35,6 +35,8 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
|||||||
else -> null
|
else -> null
|
||||||
},
|
},
|
||||||
displayKind = displayKind,
|
displayKind = displayKind,
|
||||||
|
tokenCount = tokenCount,
|
||||||
|
tps = tps,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||||
@@ -50,4 +52,6 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
|||||||
toolStatus = toolStatus?.name,
|
toolStatus = toolStatus?.name,
|
||||||
isStreaming = isStreaming,
|
isStreaming = isStreaming,
|
||||||
displayKind = displayKind,
|
displayKind = displayKind,
|
||||||
|
tokenCount = tokenCount,
|
||||||
|
tps = tps,
|
||||||
)
|
)
|
||||||
@@ -12,7 +12,7 @@ import java.io.File
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [ChatMessageEntity::class],
|
entities = [ChatMessageEntity::class],
|
||||||
version = 6,
|
version = 7,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class HermesDatabase : RoomDatabase() {
|
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 =
|
fun get(context: Context): HermesDatabase =
|
||||||
instance ?: synchronized(this) {
|
instance ?: synchronized(this) {
|
||||||
// SQLCipher can't open plaintext SQLite databases — if an old
|
// SQLCipher can't open plaintext SQLite databases — if an old
|
||||||
@@ -97,6 +109,7 @@ abstract class HermesDatabase : RoomDatabase() {
|
|||||||
MIGRATION_3_4,
|
MIGRATION_3_4,
|
||||||
MIGRATION_4_5,
|
MIGRATION_4_5,
|
||||||
MIGRATION_5_6,
|
MIGRATION_5_6,
|
||||||
|
MIGRATION_6_7,
|
||||||
).fallbackToDestructiveMigration(false)
|
).fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
.also { instance = it }
|
.also { instance = it }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
|
|||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.doubleOrNull
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
import kotlinx.serialization.json.longOrNull
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -50,10 +51,18 @@ data class SessionMessage(
|
|||||||
val display_kind: String? = null,
|
val display_kind: String? = null,
|
||||||
/** Display-only metadata for the marker (e.g. delegation result counts). */
|
/** Display-only metadata for the marker (e.g. delegation result counts). */
|
||||||
val display_metadata: JsonElement? = null,
|
val display_metadata: JsonElement? = null,
|
||||||
|
/** Token count recorded by the backend. */
|
||||||
|
val token_count: JsonElement? = null,
|
||||||
) {
|
) {
|
||||||
val timestampText: String?
|
val timestampText: String?
|
||||||
get() = (timestamp as? JsonPrimitive)?.content
|
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?
|
val timestampEpochMs: Long?
|
||||||
get() {
|
get() {
|
||||||
val prim = timestamp as? JsonPrimitive ?: return null
|
val prim = timestamp as? JsonPrimitive ?: return null
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ data class UsageSnapshotResponse(
|
|||||||
val contextMax: Long? = null,
|
val contextMax: Long? = null,
|
||||||
/** Cumulative session total tokens. */
|
/** Cumulative session total tokens. */
|
||||||
val totalTokens: Long? = null,
|
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(),
|
contextUsed = (usageMap["context_used"] as? Number)?.toLong(),
|
||||||
contextMax = (usageMap["context_max"] as? Number)?.toLong(),
|
contextMax = (usageMap["context_max"] as? Number)?.toLong(),
|
||||||
totalTokens = (usageMap["total"] as? Number)?.toLong(),
|
totalTokens = (usageMap["total"] as? Number)?.toLong(),
|
||||||
|
avgTps = (usageMap["avg_tps"] as? Number)?.toDouble(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ object EventParser {
|
|||||||
"message.complete" -> {
|
"message.complete" -> {
|
||||||
val text = payload?.get("text") as? String ?: ""
|
val text = payload?.get("text") as? String ?: ""
|
||||||
val reasoning = payload?.get("reasoning") as? String
|
val reasoning = payload?.get("reasoning") as? String
|
||||||
WsEvent.MessageComplete(text, sessionId, reasoning)
|
WsEvent.MessageComplete(text, sessionId, reasoning, rawPayload = payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
"message.done" -> {
|
"message.done" -> {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ sealed class WsEvent {
|
|||||||
val reasoning: String? = null,
|
val reasoning: String? = null,
|
||||||
/** Stored session id captured before background disconnect clears the active mapping. */
|
/** Stored session id captured before background disconnect clears the active mapping. */
|
||||||
val storedSessionId: String? = null,
|
val storedSessionId: String? = null,
|
||||||
|
/** Full raw payload map (including usage/avg_tps) emitted with message.complete. */
|
||||||
|
val rawPayload: Map<String, Any?>? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
data class MessageDone(
|
data class MessageDone(
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ data class GroupChatMessage(
|
|||||||
val isSystem: Boolean = false,
|
val isSystem: Boolean = false,
|
||||||
val isPass: Boolean = false,
|
val isPass: Boolean = false,
|
||||||
val toolCalls: List<GroupChatToolCall> = emptyList(),
|
val toolCalls: List<GroupChatToolCall> = emptyList(),
|
||||||
|
val tokenCount: Int? = null,
|
||||||
|
val tps: Double? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
|
const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
|
||||||
|
|||||||
@@ -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.GroupChatMessage
|
||||||
import com.m57.hermescontrol.ui.bots.group.STOPPED_SYSTEM_TEXT
|
import com.m57.hermescontrol.ui.bots.group.STOPPED_SYSTEM_TEXT
|
||||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
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.chat.formatTimestamp
|
||||||
import com.m57.hermescontrol.ui.common.BotAvatar
|
import com.m57.hermescontrol.ui.common.BotAvatar
|
||||||
import com.m57.hermescontrol.util.BidiUtils
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
@@ -195,6 +196,26 @@ fun GroupMessageCard(
|
|||||||
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
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),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,6 +257,44 @@ fun ChatBubble(
|
|||||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ data class ChatMessage(
|
|||||||
* When present, rendered at the bottom beside the copy button.
|
* When present, rendered at the bottom beside the copy button.
|
||||||
*/
|
*/
|
||||||
val finishTimestamp: Long? = null,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ internal fun mapServerMessages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val tokenCount = msg.tokenCount ?: TokenEstimator.estimate(finalContent).takeIf { it > 0 }
|
||||||
mapped.add(
|
mapped.add(
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
id = restId,
|
id = restId,
|
||||||
@@ -182,6 +183,7 @@ internal fun mapServerMessages(
|
|||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
displayKind = msg.display_kind,
|
displayKind = msg.display_kind,
|
||||||
|
tokenCount = tokenCount,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ data class ChatUiState(
|
|||||||
// until the first successful session.usage fetch) — drives the
|
// until the first successful session.usage fetch) — drives the
|
||||||
// "compressed ×N" badge on the context chip.
|
// "compressed ×N" badge on the context chip.
|
||||||
val compressionCount: Int? = null,
|
val compressionCount: Int? = null,
|
||||||
|
/** Rolling output tokens/sec over the last ~10 calls. */
|
||||||
|
val latestTps: Double? = null,
|
||||||
// Attachment state
|
// Attachment state
|
||||||
val pendingAttachments: List<Attachment> = emptyList(),
|
val pendingAttachments: List<Attachment> = emptyList(),
|
||||||
/** One-shot composer recovery after an attachment is rejected before send. */
|
/** One-shot composer recovery after an attachment is rejected before send. */
|
||||||
@@ -1298,6 +1300,7 @@ class ChatViewModel(
|
|||||||
role = MessageRole.USER,
|
role = MessageRole.USER,
|
||||||
content = text,
|
content = text,
|
||||||
attachments = if (attachments.isNotEmpty()) attachments else null,
|
attachments = if (attachments.isNotEmpty()) attachments else null,
|
||||||
|
tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update UI immediately
|
// Update UI immediately
|
||||||
@@ -1342,6 +1345,7 @@ class ChatViewModel(
|
|||||||
role = MessageRole.USER,
|
role = MessageRole.USER,
|
||||||
content = text,
|
content = text,
|
||||||
attachments = if (attachments.isNotEmpty()) attachments else null,
|
attachments = if (attachments.isNotEmpty()) attachments else null,
|
||||||
|
tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Upload attachments then submit prompt
|
// Upload attachments then submit prompt
|
||||||
@@ -1624,7 +1628,12 @@ class ChatViewModel(
|
|||||||
|
|
||||||
val displayContent =
|
val displayContent =
|
||||||
if (result is SlashResult.QueuePrompt) result.displayContent else command
|
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
|
val sessionId = _uiState.value.currentSessionId
|
||||||
|
|
||||||
_uiState.update { it.copy(messages = it.messages + userMsg) }
|
_uiState.update { it.copy(messages = it.messages + userMsg) }
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ object ChatWsEventReducer {
|
|||||||
streamingState.streamingMessage.copy(
|
streamingState.streamingMessage.copy(
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
|
||||||
|
tps = state.latestTps?.takeIf { it > 0.0 },
|
||||||
)
|
)
|
||||||
orphan = finalized
|
orphan = finalized
|
||||||
state.copy(
|
state.copy(
|
||||||
@@ -379,17 +381,25 @@ object ChatWsEventReducer {
|
|||||||
streamingState = streamingState.copy(streamingMessage = null),
|
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 =
|
val msg =
|
||||||
streaming?.copy(
|
streaming?.copy(
|
||||||
content = text,
|
content = text,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
tokenCount = tokenCount,
|
||||||
|
tps = tps,
|
||||||
) ?: ChatMessage(
|
) ?: ChatMessage(
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
content = text,
|
content = text,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
tokenCount = tokenCount,
|
||||||
|
tps = tps,
|
||||||
)
|
)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
val sid = state.currentSessionId
|
val sid = state.currentSessionId
|
||||||
@@ -435,11 +445,14 @@ object ChatWsEventReducer {
|
|||||||
} else {
|
} else {
|
||||||
streaming.reasoningText
|
streaming.reasoningText
|
||||||
}
|
}
|
||||||
|
val tokenCount = TokenEstimator.estimate(streaming.content).takeIf { it > 0 }
|
||||||
val msg =
|
val msg =
|
||||||
streaming.copy(
|
streaming.copy(
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
tokenCount = streaming.tokenCount ?: tokenCount,
|
||||||
|
tps = streaming.tps ?: state.latestTps?.takeIf { it > 0.0 },
|
||||||
)
|
)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
val sid = state.currentSessionId
|
val sid = state.currentSessionId
|
||||||
@@ -504,6 +517,8 @@ object ChatWsEventReducer {
|
|||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
|
||||||
|
tps = state.latestTps?.takeIf { it > 0.0 },
|
||||||
)
|
)
|
||||||
orphanToPersist = finalized
|
orphanToPersist = finalized
|
||||||
state.copy(
|
state.copy(
|
||||||
@@ -962,6 +977,9 @@ object ChatWsEventReducer {
|
|||||||
if (snapshot.contextMax != null && snapshot.contextMax > 0L) {
|
if (snapshot.contextMax != null && snapshot.contextMax > 0L) {
|
||||||
updatedState = updatedState.copy(fullContextTokens = snapshot.contextMax)
|
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)
|
return ReducerResult(state = updatedState, streamingState = streamingState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
|
|||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
||||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||||
|
import com.m57.hermescontrol.ui.chat.TokenEstimator
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -171,6 +172,44 @@ internal fun FullBleedAgentMessage(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
modifier = Modifier.testTag("fullbleed_finish_time"),
|
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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,8 @@
|
|||||||
<string name="content_desc_scroll_to_bottom">التمرير إلى الأسفل</string>
|
<string name="content_desc_scroll_to_bottom">التمرير إلى الأسفل</string>
|
||||||
<string name="content_desc_copy">نسخ</string>
|
<string name="content_desc_copy">نسخ</string>
|
||||||
<string name="content_desc_edit">تعديل</string>
|
<string name="content_desc_edit">تعديل</string>
|
||||||
|
<string name="chat_msg_tokens">%1$s رمز</string>
|
||||||
|
<string name="chat_msg_tps">%1$s رمز/ث</string>
|
||||||
<string name="chat_attach_remove_desc">إزالة المرفق</string>
|
<string name="chat_attach_remove_desc">إزالة المرفق</string>
|
||||||
<string name="chat_camera_error">تعذر التقاط الصورة</string>
|
<string name="chat_camera_error">تعذر التقاط الصورة</string>
|
||||||
<string name="settings_sec_connection">الاتصال</string>
|
<string name="settings_sec_connection">الاتصال</string>
|
||||||
|
|||||||
@@ -165,6 +165,8 @@
|
|||||||
<string name="content_desc_scroll_to_bottom">맨 아래로</string>
|
<string name="content_desc_scroll_to_bottom">맨 아래로</string>
|
||||||
<string name="content_desc_copy">복사</string>
|
<string name="content_desc_copy">복사</string>
|
||||||
<string name="content_desc_edit">편집</string>
|
<string name="content_desc_edit">편집</string>
|
||||||
|
<string name="chat_msg_tokens">%1$s 토큰</string>
|
||||||
|
<string name="chat_msg_tps">%1$s t/s</string>
|
||||||
|
|
||||||
<!-- Attachment / File Picker -->
|
<!-- Attachment / File Picker -->
|
||||||
<string name="chat_attach_remove_desc">첨부 제거</string>
|
<string name="chat_attach_remove_desc">첨부 제거</string>
|
||||||
|
|||||||
@@ -168,6 +168,8 @@
|
|||||||
<string name="content_desc_scroll_to_bottom">滚动到底部</string>
|
<string name="content_desc_scroll_to_bottom">滚动到底部</string>
|
||||||
<string name="content_desc_copy">复制</string>
|
<string name="content_desc_copy">复制</string>
|
||||||
<string name="content_desc_edit">编辑</string>
|
<string name="content_desc_edit">编辑</string>
|
||||||
|
<string name="chat_msg_tokens">%1$s tok</string>
|
||||||
|
<string name="chat_msg_tps">%1$s t/s</string>
|
||||||
<string name="chat_attach_remove_desc">移除附件</string>
|
<string name="chat_attach_remove_desc">移除附件</string>
|
||||||
<string name="chat_camera_error">拍照失败</string>
|
<string name="chat_camera_error">拍照失败</string>
|
||||||
<string name="settings_sec_connection">连接</string>
|
<string name="settings_sec_connection">连接</string>
|
||||||
|
|||||||
@@ -186,6 +186,8 @@
|
|||||||
<string name="content_desc_scroll_to_bottom">Scroll to bottom</string>
|
<string name="content_desc_scroll_to_bottom">Scroll to bottom</string>
|
||||||
<string name="content_desc_copy">Copy</string>
|
<string name="content_desc_copy">Copy</string>
|
||||||
<string name="content_desc_edit">Edit</string>
|
<string name="content_desc_edit">Edit</string>
|
||||||
|
<string name="chat_msg_tokens">%1$s tok</string>
|
||||||
|
<string name="chat_msg_tps">%1$s t/s</string>
|
||||||
|
|
||||||
<!-- Attachment / File Picker -->
|
<!-- Attachment / File Picker -->
|
||||||
<string name="chat_attach_remove_desc">Remove attachment</string>
|
<string name="chat_attach_remove_desc">Remove attachment</string>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class ChatMessageMapperTest {
|
|||||||
toolName = "search",
|
toolName = "search",
|
||||||
toolStatus = "RUNNING",
|
toolStatus = "RUNNING",
|
||||||
isStreaming = true,
|
isStreaming = true,
|
||||||
|
tokenCount = 42,
|
||||||
|
tps = 55.5,
|
||||||
)
|
)
|
||||||
|
|
||||||
val ui = entity.toUiModel()
|
val ui = entity.toUiModel()
|
||||||
@@ -35,6 +37,8 @@ class ChatMessageMapperTest {
|
|||||||
assertEquals("search", ui.toolName)
|
assertEquals("search", ui.toolName)
|
||||||
assertEquals(ToolStatus.RUNNING, ui.toolStatus)
|
assertEquals(ToolStatus.RUNNING, ui.toolStatus)
|
||||||
assertTrue(ui.isStreaming)
|
assertTrue(ui.isStreaming)
|
||||||
|
assertEquals(42, ui.tokenCount)
|
||||||
|
assertEquals(55.5, ui.tps ?: 0.0, 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -49,6 +53,8 @@ class ChatMessageMapperTest {
|
|||||||
toolName = null,
|
toolName = null,
|
||||||
toolStatus = null,
|
toolStatus = null,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
tokenCount = 100,
|
||||||
|
tps = 24.8,
|
||||||
)
|
)
|
||||||
|
|
||||||
val entity = ui.toEntity("session-b")
|
val entity = ui.toEntity("session-b")
|
||||||
@@ -62,6 +68,8 @@ class ChatMessageMapperTest {
|
|||||||
assertNull(entity.toolName)
|
assertNull(entity.toolName)
|
||||||
assertNull(entity.toolStatus)
|
assertNull(entity.toolStatus)
|
||||||
assertFalse(entity.isStreaming)
|
assertFalse(entity.isStreaming)
|
||||||
|
assertEquals(100, entity.tokenCount)
|
||||||
|
assertEquals(24.8, entity.tps ?: 0.0, 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -70,4 +70,19 @@ class HermesDatabaseMigrationsTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun migration6to7_executesTokenCountAndTpsColumnAdditionOnSQLiteConnection() =
|
||||||
|
runBlocking {
|
||||||
|
val connection = mockk<SQLiteConnection>(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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user