feat(chat): add configurable message stats (#1134)
This commit is contained in:
20 files changed
+606
-44
No files matched your search
+165
-1
@@ -1,12 +1,18 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.test.assertCountEquals
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
@@ -44,7 +50,17 @@ class FullBleedChatListTest {
|
|||||||
content: String = "content-$id",
|
content: String = "content-$id",
|
||||||
toolStatus: ToolStatus? = null,
|
toolStatus: ToolStatus? = null,
|
||||||
isStreaming: Boolean = false,
|
isStreaming: Boolean = false,
|
||||||
) = ChatMessage(id = id, role = role, content = content, toolStatus = toolStatus, isStreaming = isStreaming)
|
tokenCount: Int? = null,
|
||||||
|
tps: Double? = null,
|
||||||
|
) = ChatMessage(
|
||||||
|
id = id,
|
||||||
|
role = role,
|
||||||
|
content = content,
|
||||||
|
toolStatus = toolStatus,
|
||||||
|
isStreaming = isStreaming,
|
||||||
|
tokenCount = tokenCount,
|
||||||
|
tps = tps,
|
||||||
|
)
|
||||||
|
|
||||||
private fun render(
|
private fun render(
|
||||||
messages: List<ChatMessage>,
|
messages: List<ChatMessage>,
|
||||||
@@ -52,6 +68,10 @@ class FullBleedChatListTest {
|
|||||||
streamingState: StreamingState = StreamingState(streamingMessage = streamingMessage),
|
streamingState: StreamingState = StreamingState(streamingMessage = streamingMessage),
|
||||||
isAgentTyping: Boolean = streamingMessage?.isStreaming == true,
|
isAgentTyping: Boolean = streamingMessage?.isStreaming == true,
|
||||||
clarify: Boolean = false,
|
clarify: Boolean = false,
|
||||||
|
messageStatsEnabled: Boolean = false,
|
||||||
|
showUserMessageTokens: Boolean = true,
|
||||||
|
showAssistantMessageTokens: Boolean = true,
|
||||||
|
showTokensPerSecond: Boolean = true,
|
||||||
) {
|
) {
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
val listState = LazyListState()
|
val listState = LazyListState()
|
||||||
@@ -62,6 +82,10 @@ class FullBleedChatListTest {
|
|||||||
searchState = ChatSearchState(),
|
searchState = ChatSearchState(),
|
||||||
typingEffectEnabled = false,
|
typingEffectEnabled = false,
|
||||||
typingEffectDelayMs = 30,
|
typingEffectDelayMs = 30,
|
||||||
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
|
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||||
|
showTokensPerSecond = showTokensPerSecond,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isLoadingOlder = false,
|
isLoadingOlder = false,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
@@ -133,6 +157,146 @@ class FullBleedChatListTest {
|
|||||||
composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist()
|
composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageStats_masterOff_hidesAllMetadata() {
|
||||||
|
render(
|
||||||
|
messages =
|
||||||
|
listOf(
|
||||||
|
msg("u1", MessageRole.USER, tokenCount = 123),
|
||||||
|
msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageStats_userTokensOnly_showsOnlyUserTokens() {
|
||||||
|
render(
|
||||||
|
messages =
|
||||||
|
listOf(
|
||||||
|
msg("u1", MessageRole.USER, tokenCount = 123),
|
||||||
|
msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5),
|
||||||
|
),
|
||||||
|
messageStatsEnabled = true,
|
||||||
|
showAssistantMessageTokens = false,
|
||||||
|
showTokensPerSecond = false,
|
||||||
|
)
|
||||||
|
composeTestRule.onNodeWithTag("bubble_token_count").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageStats_assistantTokensOnly_showsOnlyAssistantTokens() {
|
||||||
|
render(
|
||||||
|
messages =
|
||||||
|
listOf(
|
||||||
|
msg("u1", MessageRole.USER, tokenCount = 123),
|
||||||
|
msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5),
|
||||||
|
),
|
||||||
|
messageStatsEnabled = true,
|
||||||
|
showUserMessageTokens = false,
|
||||||
|
showTokensPerSecond = false,
|
||||||
|
)
|
||||||
|
composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_token_count").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageStats_tpsOnly_showsOnlyAssistantTps() {
|
||||||
|
render(
|
||||||
|
messages =
|
||||||
|
listOf(
|
||||||
|
msg("u1", MessageRole.USER, tokenCount = 123),
|
||||||
|
msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5),
|
||||||
|
),
|
||||||
|
messageStatsEnabled = true,
|
||||||
|
showUserMessageTokens = false,
|
||||||
|
showAssistantMessageTokens = false,
|
||||||
|
)
|
||||||
|
composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_tps").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageStats_allEnabled_showsUserTokensAssistantTokensAndTps() {
|
||||||
|
render(
|
||||||
|
messages =
|
||||||
|
listOf(
|
||||||
|
msg("u1", MessageRole.USER, tokenCount = 123),
|
||||||
|
msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5),
|
||||||
|
),
|
||||||
|
messageStatsEnabled = true,
|
||||||
|
)
|
||||||
|
composeTestRule.onNodeWithTag("bubble_token_count").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_token_count").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_tps").assertIsDisplayed()
|
||||||
|
composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(0)
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageStats_nullAndZeroValues_areHidden() {
|
||||||
|
render(
|
||||||
|
messages =
|
||||||
|
listOf(
|
||||||
|
msg("u1", MessageRole.USER, tokenCount = 0),
|
||||||
|
msg("a1", MessageRole.ASSISTANT, tokenCount = 0, tps = 0.0),
|
||||||
|
),
|
||||||
|
messageStatsEnabled = true,
|
||||||
|
)
|
||||||
|
composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageStats_streamingAssistant_hidesFinalMetadata() {
|
||||||
|
render(
|
||||||
|
messages = listOf(msg("u1", MessageRole.USER)),
|
||||||
|
streamingMessage =
|
||||||
|
msg(
|
||||||
|
"s1",
|
||||||
|
MessageRole.ASSISTANT,
|
||||||
|
content = "",
|
||||||
|
isStreaming = true,
|
||||||
|
tokenCount = 456,
|
||||||
|
tps = 42.5,
|
||||||
|
),
|
||||||
|
isAgentTyping = true,
|
||||||
|
messageStatsEnabled = true,
|
||||||
|
)
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithTag("typing_indicator").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun assistantStats_wrapInsideConstrainedWidth() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.width(220.dp).testTag("stats_host"),
|
||||||
|
) {
|
||||||
|
FullBleedAgentMessage(
|
||||||
|
message = msg("a1", MessageRole.ASSISTANT, tokenCount = 123456789, tps = 9876.5),
|
||||||
|
messageStatsEnabled = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
val host = composeTestRule.onNodeWithTag("stats_host").getUnclippedBoundsInRoot()
|
||||||
|
val token = composeTestRule.onNodeWithTag("fullbleed_token_count").getUnclippedBoundsInRoot()
|
||||||
|
val tps = composeTestRule.onNodeWithTag("fullbleed_tps").getUnclippedBoundsInRoot()
|
||||||
|
assert(token.left >= host.left)
|
||||||
|
assert(token.right <= host.right)
|
||||||
|
assert(tps.left >= host.left)
|
||||||
|
assert(tps.right <= host.right)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun streaming_and_clarify_stillRender() {
|
fun streaming_and_clarify_stillRender() {
|
||||||
render(
|
render(
|
||||||
|
|||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package com.m57.hermescontrol.ui.settings.components
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
import androidx.compose.ui.test.assertIsOff
|
||||||
|
import androidx.compose.ui.test.assertIsOn
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@MediumTest
|
||||||
|
class ChatSectionTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageStatsMasterOff_keepsChildrenVisibleCheckedAndDisabled() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
ChatSection(
|
||||||
|
typingEffectEnabled = false,
|
||||||
|
onTypingEffectEnabledChange = {},
|
||||||
|
typingEffectDelayMs = 30,
|
||||||
|
onTypingEffectDelayMsChange = {},
|
||||||
|
messageStatsEnabled = false,
|
||||||
|
showUserMessageTokens = true,
|
||||||
|
showAssistantMessageTokens = true,
|
||||||
|
showTokensPerSecond = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("settings_message_stats").assertIsOff().assertIsEnabled()
|
||||||
|
composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsOn().assertIsNotEnabled()
|
||||||
|
composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsOn().assertIsNotEnabled()
|
||||||
|
composeTestRule.onNodeWithTag("settings_tps").assertIsOn().assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageStatsMasterOn_enablesChildrenAndRoutesCallbacks() {
|
||||||
|
var masterChanges = 0
|
||||||
|
var userChanges = 0
|
||||||
|
var assistantChanges = 0
|
||||||
|
var tpsChanges = 0
|
||||||
|
composeTestRule.setContent {
|
||||||
|
ChatSection(
|
||||||
|
typingEffectEnabled = false,
|
||||||
|
onTypingEffectEnabledChange = {},
|
||||||
|
typingEffectDelayMs = 30,
|
||||||
|
onTypingEffectDelayMsChange = {},
|
||||||
|
messageStatsEnabled = true,
|
||||||
|
onMessageStatsEnabledChange = { masterChanges++ },
|
||||||
|
showUserMessageTokens = true,
|
||||||
|
onUserMessageTokensChange = { userChanges++ },
|
||||||
|
showAssistantMessageTokens = true,
|
||||||
|
onAssistantMessageTokensChange = { assistantChanges++ },
|
||||||
|
showTokensPerSecond = true,
|
||||||
|
onTokensPerSecondChange = { tpsChanges++ },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsEnabled().performClick()
|
||||||
|
composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsEnabled().performClick()
|
||||||
|
composeTestRule.onNodeWithTag("settings_tps").assertIsEnabled().performClick()
|
||||||
|
assertEquals(0, masterChanges)
|
||||||
|
assertEquals(1, userChanges)
|
||||||
|
assertEquals(1, assistantChanges)
|
||||||
|
assertEquals(1, tpsChanges)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("settings_message_stats").performClick()
|
||||||
|
assertEquals(1, masterChanges)
|
||||||
|
assertEquals(1, userChanges)
|
||||||
|
assertEquals(1, assistantChanges)
|
||||||
|
assertEquals(1, tpsChanges)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ data class ServerStoreState(
|
|||||||
val typingEffectEnabled: Boolean = true,
|
val typingEffectEnabled: Boolean = true,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
val chatFontScale: Float = 1.0f,
|
val chatFontScale: Float = 1.0f,
|
||||||
|
val messageStatsEnabled: Boolean = false,
|
||||||
|
val showUserMessageTokens: Boolean = true,
|
||||||
|
val showAssistantMessageTokens: Boolean = true,
|
||||||
|
val showTokensPerSecond: Boolean = true,
|
||||||
// App display language. "system" = follow device locale; otherwise a BCP-47
|
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||||
val appLanguage: String = "system",
|
val appLanguage: String = "system",
|
||||||
|
|||||||
@@ -713,6 +713,32 @@ object AuthManager {
|
|||||||
serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
|
serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Message stats ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun isMessageStatsEnabled(): Boolean = serverStore.getLatestState().messageStatsEnabled
|
||||||
|
|
||||||
|
fun setMessageStatsEnabled(enabled: Boolean) {
|
||||||
|
serverStore.update { it.copy(messageStatsEnabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isUserMessageTokensEnabled(): Boolean = serverStore.getLatestState().showUserMessageTokens
|
||||||
|
|
||||||
|
fun setUserMessageTokensEnabled(enabled: Boolean) {
|
||||||
|
serverStore.update { it.copy(showUserMessageTokens = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAssistantMessageTokensEnabled(): Boolean = serverStore.getLatestState().showAssistantMessageTokens
|
||||||
|
|
||||||
|
fun setAssistantMessageTokensEnabled(enabled: Boolean) {
|
||||||
|
serverStore.update { it.copy(showAssistantMessageTokens = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isTokensPerSecondEnabled(): Boolean = serverStore.getLatestState().showTokensPerSecond
|
||||||
|
|
||||||
|
fun setTokensPerSecondEnabled(enabled: Boolean) {
|
||||||
|
serverStore.update { it.copy(showTokensPerSecond = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Chat Font Scale (issue #1004) ───────────────────────────────────
|
// ── Chat Font Scale (issue #1004) ───────────────────────────────────
|
||||||
|
|
||||||
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
|
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import java.time.format.DateTimeFormatter
|
|||||||
* renderer (issue #866). Agent prose renders full-bleed; user messages keep
|
* renderer (issue #866). Agent prose renders full-bleed; user messages keep
|
||||||
* this bubble so the conversation stays scannable.
|
* this bubble so the conversation stays scannable.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatBubble(
|
fun ChatBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
@@ -106,6 +107,8 @@ fun ChatBubble(
|
|||||||
openingAttachmentPath: String? = null,
|
openingAttachmentPath: String? = null,
|
||||||
canSaveAttachment: Boolean = true,
|
canSaveAttachment: Boolean = true,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
|
messageStatsEnabled: Boolean = false,
|
||||||
|
showUserMessageTokens: Boolean = true,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
@@ -222,12 +225,12 @@ fun ChatBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!message.isStreaming) {
|
if (!message.isStreaming) {
|
||||||
Row(
|
FlowRow(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.End)
|
.align(Alignment.End)
|
||||||
.padding(top = 4.dp),
|
.padding(top = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -257,43 +260,28 @@ 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) {
|
if (messageStatsEnabled && showUserMessageTokens &&
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
message.tokenCount != null && message.tokenCount > 0
|
||||||
Text(
|
) {
|
||||||
text = "•",
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
style = MaterialTheme.typography.labelSmall,
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
color = userBubbleTextColor.copy(alpha = 0.4f),
|
Text(
|
||||||
)
|
text = "•",
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
style = MaterialTheme.typography.labelSmall,
|
||||||
Text(
|
color = userBubbleTextColor.copy(alpha = 0.4f),
|
||||||
text =
|
)
|
||||||
stringResource(
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
R.string.chat_msg_tokens,
|
Text(
|
||||||
TokenEstimator.formatTokenCount(message.tokenCount),
|
text =
|
||||||
),
|
stringResource(
|
||||||
color = userBubbleTextColor.copy(alpha = 0.7f),
|
R.string.chat_msg_tokens,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
TokenEstimator.formatTokenCount(message.tokenCount),
|
||||||
modifier = Modifier.testTag("bubble_token_count"),
|
),
|
||||||
)
|
color = userBubbleTextColor.copy(alpha = 0.7f),
|
||||||
}
|
style = MaterialTheme.typography.labelSmall,
|
||||||
if (message.tps != null && message.tps > 0.0) {
|
modifier = Modifier.testTag("bubble_token_count"),
|
||||||
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"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -671,6 +671,10 @@ fun ChatScreen(
|
|||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
typingEffectEnabled = state.typingEffectEnabled,
|
typingEffectEnabled = state.typingEffectEnabled,
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||||
|
messageStatsEnabled = state.messageStatsEnabled,
|
||||||
|
showUserMessageTokens = state.showUserMessageTokens,
|
||||||
|
showAssistantMessageTokens = state.showAssistantMessageTokens,
|
||||||
|
showTokensPerSecond = state.showTokensPerSecond,
|
||||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading,
|
||||||
isLoadingOlder = state.isLoadingOlder,
|
isLoadingOlder = state.isLoadingOlder,
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ data class ChatUiState(
|
|||||||
// Cached settings
|
// Cached settings
|
||||||
val typingEffectEnabled: Boolean = true,
|
val typingEffectEnabled: Boolean = true,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
|
val messageStatsEnabled: Boolean = false,
|
||||||
|
val showUserMessageTokens: Boolean = true,
|
||||||
|
val showAssistantMessageTokens: Boolean = true,
|
||||||
|
val showTokensPerSecond: Boolean = true,
|
||||||
// Commands catalog
|
// Commands catalog
|
||||||
val commandCatalog: CommandCatalog = CommandCatalog(),
|
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||||
// Per-command usage counts for the slash-autocomplete ranking (issue
|
// Per-command usage counts for the slash-autocomplete ranking (issue
|
||||||
@@ -2530,6 +2534,10 @@ class ChatViewModel(
|
|||||||
state.copy(
|
state.copy(
|
||||||
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
|
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
|
||||||
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
|
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
|
||||||
|
messageStatsEnabled = AuthManager.isMessageStatsEnabled(),
|
||||||
|
showUserMessageTokens = AuthManager.isUserMessageTokensEnabled(),
|
||||||
|
showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled(),
|
||||||
|
showTokensPerSecond = AuthManager.isTokensPerSecondEnabled(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||||
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -36,6 +39,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
|
||||||
@@ -48,6 +52,7 @@ import kotlinx.coroutines.launch
|
|||||||
* trailing copy affordance. User messages keep their bubbles; this composable
|
* trailing copy affordance. User messages keep their bubbles; this composable
|
||||||
* is only used for ASSISTANT messages.
|
* is only used for ASSISTANT messages.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun FullBleedAgentMessage(
|
internal fun FullBleedAgentMessage(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
@@ -60,6 +65,9 @@ internal fun FullBleedAgentMessage(
|
|||||||
openingAttachmentPath: String? = null,
|
openingAttachmentPath: String? = null,
|
||||||
canSaveAttachment: Boolean = true,
|
canSaveAttachment: Boolean = true,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
|
messageStatsEnabled: Boolean = false,
|
||||||
|
showAssistantMessageTokens: Boolean = true,
|
||||||
|
showTokensPerSecond: Boolean = true,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val textColor = MaterialTheme.colorScheme.onSurface
|
val textColor = MaterialTheme.colorScheme.onSurface
|
||||||
@@ -127,8 +135,16 @@ internal fun FullBleedAgentMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!message.isStreaming && message.content.isNotBlank()) {
|
if (!message.isStreaming && message.content.isNotBlank()) {
|
||||||
Row(
|
val showTokenStat =
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
messageStatsEnabled && showAssistantMessageTokens &&
|
||||||
|
message.tokenCount != null && message.tokenCount > 0
|
||||||
|
val showTpsStat =
|
||||||
|
messageStatsEnabled && showTokensPerSecond &&
|
||||||
|
message.tps != null && message.tps > 0.0
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -146,7 +162,40 @@ internal fun FullBleedAgentMessage(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showTokenStat) {
|
||||||
|
AssistantStatItem(
|
||||||
|
value =
|
||||||
|
stringResource(
|
||||||
|
R.string.chat_msg_tokens,
|
||||||
|
TokenEstimator.formatTokenCount(message.tokenCount),
|
||||||
|
),
|
||||||
|
testTag = "fullbleed_token_count",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTpsStat) {
|
||||||
|
AssistantStatItem(
|
||||||
|
value =
|
||||||
|
stringResource(
|
||||||
|
R.string.chat_msg_tps,
|
||||||
|
TokenEstimator.formatTps(message.tps),
|
||||||
|
),
|
||||||
|
testTag = "fullbleed_tps",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AssistantStatItem(
|
||||||
|
value: String,
|
||||||
|
testTag: String,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.testTag(testTag),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -64,6 +64,10 @@ fun FullBleedChatList(
|
|||||||
searchState: ChatSearchState,
|
searchState: ChatSearchState,
|
||||||
typingEffectEnabled: Boolean,
|
typingEffectEnabled: Boolean,
|
||||||
typingEffectDelayMs: Int,
|
typingEffectDelayMs: Int,
|
||||||
|
messageStatsEnabled: Boolean = false,
|
||||||
|
showUserMessageTokens: Boolean = true,
|
||||||
|
showAssistantMessageTokens: Boolean = true,
|
||||||
|
showTokensPerSecond: Boolean = true,
|
||||||
maxToolCallsPerTurn: Int? = null,
|
maxToolCallsPerTurn: Int? = null,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
isLoadingOlder: Boolean,
|
isLoadingOlder: Boolean,
|
||||||
@@ -184,6 +188,8 @@ fun FullBleedChatList(
|
|||||||
savingAttachmentPath = savingAttachmentPath,
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
openingAttachmentPath = openingAttachmentPath,
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,6 +254,9 @@ fun FullBleedChatList(
|
|||||||
openingAttachmentPath = openingAttachmentPath,
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
canSaveAttachment = savingAttachmentPath == null,
|
canSaveAttachment = savingAttachmentPath == null,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
|
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||||
|
showTokensPerSecond = showTokensPerSecond,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,6 +365,8 @@ private fun renderChatBubble(
|
|||||||
savingAttachmentPath: String?,
|
savingAttachmentPath: String?,
|
||||||
openingAttachmentPath: String?,
|
openingAttachmentPath: String?,
|
||||||
onImageClick: (ImageViewerModel) -> Unit,
|
onImageClick: (ImageViewerModel) -> Unit,
|
||||||
|
messageStatsEnabled: Boolean,
|
||||||
|
showUserMessageTokens: Boolean,
|
||||||
) {
|
) {
|
||||||
ChatBubble(
|
ChatBubble(
|
||||||
message = message,
|
message = message,
|
||||||
@@ -367,5 +378,7 @@ private fun renderChatBubble(
|
|||||||
openingAttachmentPath = openingAttachmentPath,
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
canSaveAttachment = savingAttachmentPath == null,
|
canSaveAttachment = savingAttachmentPath == null,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -199,6 +199,14 @@ internal fun SettingsChatPage(
|
|||||||
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
|
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
|
||||||
chatFontScale = state.chatFontScale,
|
chatFontScale = state.chatFontScale,
|
||||||
onChatFontScaleChange = viewModel::onChatFontScaleChange,
|
onChatFontScaleChange = viewModel::onChatFontScaleChange,
|
||||||
|
messageStatsEnabled = state.messageStatsEnabled,
|
||||||
|
onMessageStatsEnabledChange = viewModel::onMessageStatsEnabledChange,
|
||||||
|
showUserMessageTokens = state.showUserMessageTokens,
|
||||||
|
onUserMessageTokensChange = viewModel::onUserMessageTokensChange,
|
||||||
|
showAssistantMessageTokens = state.showAssistantMessageTokens,
|
||||||
|
onAssistantMessageTokensChange = viewModel::onAssistantMessageTokensChange,
|
||||||
|
showTokensPerSecond = state.showTokensPerSecond,
|
||||||
|
onTokensPerSecondChange = viewModel::onTokensPerSecondChange,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ data class SettingsUiState(
|
|||||||
val typingEffectEnabled: Boolean = false,
|
val typingEffectEnabled: Boolean = false,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
val chatFontScale: Float = 1.0f,
|
val chatFontScale: Float = 1.0f,
|
||||||
|
val messageStatsEnabled: Boolean = false,
|
||||||
|
val showUserMessageTokens: Boolean = true,
|
||||||
|
val showAssistantMessageTokens: Boolean = true,
|
||||||
|
val showTokensPerSecond: Boolean = true,
|
||||||
val profiles: List<ConnectionProfile> = emptyList(),
|
val profiles: List<ConnectionProfile> = emptyList(),
|
||||||
val selectedProfileId: String? = null,
|
val selectedProfileId: String? = null,
|
||||||
val renameProfileName: String = "",
|
val renameProfileName: String = "",
|
||||||
@@ -80,6 +84,10 @@ class SettingsViewModel(
|
|||||||
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
||||||
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||||
val chatFontScale = AuthManager.getChatFontScale()
|
val chatFontScale = AuthManager.getChatFontScale()
|
||||||
|
val messageStatsEnabled = AuthManager.isMessageStatsEnabled()
|
||||||
|
val showUserMessageTokens = AuthManager.isUserMessageTokensEnabled()
|
||||||
|
val showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled()
|
||||||
|
val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled()
|
||||||
val profiles = AuthManager.getConnectionProfiles()
|
val profiles = AuthManager.getConnectionProfiles()
|
||||||
val appLanguage = AuthManager.getAppLanguage()
|
val appLanguage = AuthManager.getAppLanguage()
|
||||||
val renameProfileName =
|
val renameProfileName =
|
||||||
@@ -101,6 +109,10 @@ class SettingsViewModel(
|
|||||||
typingEffectEnabled = typingEffectEnabled,
|
typingEffectEnabled = typingEffectEnabled,
|
||||||
typingEffectDelayMs = typingEffectDelayMs,
|
typingEffectDelayMs = typingEffectDelayMs,
|
||||||
chatFontScale = chatFontScale,
|
chatFontScale = chatFontScale,
|
||||||
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
|
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||||
|
showTokensPerSecond = showTokensPerSecond,
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
selectedProfileId = selectedId,
|
selectedProfileId = selectedId,
|
||||||
renameProfileName = renameProfileName,
|
renameProfileName = renameProfileName,
|
||||||
@@ -327,6 +339,26 @@ class SettingsViewModel(
|
|||||||
AuthManager.setChatFontScale(scale)
|
AuthManager.setChatFontScale(scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onMessageStatsEnabledChange(enabled: Boolean) {
|
||||||
|
_uiState.update { it.copy(messageStatsEnabled = enabled, isSaved = false) }
|
||||||
|
AuthManager.setMessageStatsEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onUserMessageTokensChange(enabled: Boolean) {
|
||||||
|
_uiState.update { it.copy(showUserMessageTokens = enabled, isSaved = false) }
|
||||||
|
AuthManager.setUserMessageTokensEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAssistantMessageTokensChange(enabled: Boolean) {
|
||||||
|
_uiState.update { it.copy(showAssistantMessageTokens = enabled, isSaved = false) }
|
||||||
|
AuthManager.setAssistantMessageTokensEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTokensPerSecondChange(enabled: Boolean) {
|
||||||
|
_uiState.update { it.copy(showTokensPerSecond = enabled, isSaved = false) }
|
||||||
|
AuthManager.setTokensPerSecondEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear all auth credentials — logs out and returns to landing screen. */
|
/** Clear all auth credentials — logs out and returns to landing screen. */
|
||||||
fun logout() {
|
fun logout() {
|
||||||
AuthManager.setToken(null)
|
AuthManager.setToken(null)
|
||||||
@@ -355,6 +387,10 @@ class SettingsViewModel(
|
|||||||
AuthManager.setThemePreset(state.themePreset)
|
AuthManager.setThemePreset(state.themePreset)
|
||||||
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
|
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
|
||||||
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
|
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
|
||||||
|
AuthManager.setMessageStatsEnabled(state.messageStatsEnabled)
|
||||||
|
AuthManager.setUserMessageTokensEnabled(state.showUserMessageTokens)
|
||||||
|
AuthManager.setAssistantMessageTokensEnabled(state.showAssistantMessageTokens)
|
||||||
|
AuthManager.setTokensPerSecondEnabled(state.showTokensPerSecond)
|
||||||
ApiClient.rebuild()
|
ApiClient.rebuild()
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
@@ -204,6 +205,14 @@ internal fun ChatSection(
|
|||||||
onTypingEffectDelayMsChange: (Int) -> Unit,
|
onTypingEffectDelayMsChange: (Int) -> Unit,
|
||||||
chatFontScale: Float = 1.0f,
|
chatFontScale: Float = 1.0f,
|
||||||
onChatFontScaleChange: (Float) -> Unit = {},
|
onChatFontScaleChange: (Float) -> Unit = {},
|
||||||
|
messageStatsEnabled: Boolean = false,
|
||||||
|
onMessageStatsEnabledChange: (Boolean) -> Unit = {},
|
||||||
|
showUserMessageTokens: Boolean = true,
|
||||||
|
onUserMessageTokensChange: (Boolean) -> Unit = {},
|
||||||
|
showAssistantMessageTokens: Boolean = true,
|
||||||
|
onAssistantMessageTokensChange: (Boolean) -> Unit = {},
|
||||||
|
showTokensPerSecond: Boolean = true,
|
||||||
|
onTokensPerSecondChange: (Boolean) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
|
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
|
||||||
val currentIndex =
|
val currentIndex =
|
||||||
@@ -369,6 +378,53 @@ internal fun ChatSection(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_item_message_stats),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_desc_message_stats),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = messageStatsEnabled,
|
||||||
|
onCheckedChange = onMessageStatsEnabledChange,
|
||||||
|
modifier = Modifier.testTag("settings_message_stats"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MessageStatsOption(
|
||||||
|
label = stringResource(R.string.settings_item_user_message_tokens),
|
||||||
|
checked = showUserMessageTokens,
|
||||||
|
enabled = messageStatsEnabled,
|
||||||
|
onCheckedChange = onUserMessageTokensChange,
|
||||||
|
testTag = "settings_user_message_tokens",
|
||||||
|
)
|
||||||
|
MessageStatsOption(
|
||||||
|
label = stringResource(R.string.settings_item_assistant_message_tokens),
|
||||||
|
checked = showAssistantMessageTokens,
|
||||||
|
enabled = messageStatsEnabled,
|
||||||
|
onCheckedChange = onAssistantMessageTokensChange,
|
||||||
|
testTag = "settings_assistant_message_tokens",
|
||||||
|
)
|
||||||
|
MessageStatsOption(
|
||||||
|
label = stringResource(R.string.settings_item_tokens_per_second),
|
||||||
|
checked = showTokensPerSecond,
|
||||||
|
enabled = messageStatsEnabled,
|
||||||
|
onCheckedChange = onTokensPerSecondChange,
|
||||||
|
testTag = "settings_tps",
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -440,3 +496,37 @@ internal fun ChatSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageStatsOption(
|
||||||
|
label: String,
|
||||||
|
checked: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
testTag: String,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color =
|
||||||
|
if (enabled) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
enabled = enabled,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
modifier = Modifier.testTag(testTag),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,6 +184,11 @@
|
|||||||
<string name="settings_sec_chat">المحادثة</string>
|
<string name="settings_sec_chat">المحادثة</string>
|
||||||
<string name="settings_item_typing_effect">تأثير الكتابة</string>
|
<string name="settings_item_typing_effect">تأثير الكتابة</string>
|
||||||
<string name="settings_desc_typing_effect">تحريك ظهور الرسائل الجديدة كلمة بكلمة</string>
|
<string name="settings_desc_typing_effect">تحريك ظهور الرسائل الجديدة كلمة بكلمة</string>
|
||||||
|
<string name="settings_item_message_stats">إحصائيات الرسائل</string>
|
||||||
|
<string name="settings_desc_message_stats">إظهار تفاصيل الرموز وأداء التوليد في الدردشة.</string>
|
||||||
|
<string name="settings_item_user_message_tokens">رموز رسالة المستخدم</string>
|
||||||
|
<string name="settings_item_assistant_message_tokens">رموز رسالة المساعد</string>
|
||||||
|
<string name="settings_item_tokens_per_second">الرموز في الثانية (TPS)</string>
|
||||||
<string name="chat_agent_status_typing">الوكيل يكتب</string>
|
<string name="chat_agent_status_typing">الوكيل يكتب</string>
|
||||||
<string name="chat_agent_status_thinking">يفكّر…</string>
|
<string name="chat_agent_status_thinking">يفكّر…</string>
|
||||||
<string name="chat_agent_status_searching">يبحث…</string>
|
<string name="chat_agent_status_searching">يبحث…</string>
|
||||||
|
|||||||
@@ -187,6 +187,11 @@
|
|||||||
<string name="settings_sec_chat">채팅</string>
|
<string name="settings_sec_chat">채팅</string>
|
||||||
<string name="settings_item_typing_effect">타이핑 효과</string>
|
<string name="settings_item_typing_effect">타이핑 효과</string>
|
||||||
<string name="settings_desc_typing_effect">새 메시지를 단어 단위로 애니메이션</string>
|
<string name="settings_desc_typing_effect">새 메시지를 단어 단위로 애니메이션</string>
|
||||||
|
<string name="settings_item_message_stats">메시지 통계</string>
|
||||||
|
<string name="settings_desc_message_stats">채팅에서 토큰 및 생성 성능 세부정보 표시</string>
|
||||||
|
<string name="settings_item_user_message_tokens">사용자 메시지 토큰</string>
|
||||||
|
<string name="settings_item_assistant_message_tokens">어시스턴트 메시지 토큰</string>
|
||||||
|
<string name="settings_item_tokens_per_second">초당 토큰 수 (TPS)</string>
|
||||||
<string name="chat_agent_status_typing">에이전트 입력 중</string>
|
<string name="chat_agent_status_typing">에이전트 입력 중</string>
|
||||||
<string name="chat_agent_status_thinking">생각 중…</string>
|
<string name="chat_agent_status_thinking">생각 중…</string>
|
||||||
<string name="chat_agent_status_searching">검색 중…</string>
|
<string name="chat_agent_status_searching">검색 중…</string>
|
||||||
|
|||||||
@@ -184,6 +184,11 @@
|
|||||||
<string name="settings_sec_chat">对话</string>
|
<string name="settings_sec_chat">对话</string>
|
||||||
<string name="settings_item_typing_effect">打字效果</string>
|
<string name="settings_item_typing_effect">打字效果</string>
|
||||||
<string name="settings_desc_typing_effect">逐字显示新消息动画</string>
|
<string name="settings_desc_typing_effect">逐字显示新消息动画</string>
|
||||||
|
<string name="settings_item_message_stats">消息统计</string>
|
||||||
|
<string name="settings_desc_message_stats">在聊天中显示令牌和生成性能详情</string>
|
||||||
|
<string name="settings_item_user_message_tokens">用户消息令牌</string>
|
||||||
|
<string name="settings_item_assistant_message_tokens">助手消息令牌</string>
|
||||||
|
<string name="settings_item_tokens_per_second">每秒令牌数 (TPS)</string>
|
||||||
<string name="chat_agent_status_typing">助手正在输入</string>
|
<string name="chat_agent_status_typing">助手正在输入</string>
|
||||||
<string name="chat_agent_status_thinking">正在思考…</string>
|
<string name="chat_agent_status_thinking">正在思考…</string>
|
||||||
<string name="chat_agent_status_searching">正在搜索…</string>
|
<string name="chat_agent_status_searching">正在搜索…</string>
|
||||||
|
|||||||
@@ -208,6 +208,11 @@
|
|||||||
<string name="settings_sec_chat">Chat</string>
|
<string name="settings_sec_chat">Chat</string>
|
||||||
<string name="settings_item_typing_effect">Typing Effect</string>
|
<string name="settings_item_typing_effect">Typing Effect</string>
|
||||||
<string name="settings_desc_typing_effect">Animate new messages word by word</string>
|
<string name="settings_desc_typing_effect">Animate new messages word by word</string>
|
||||||
|
<string name="settings_item_message_stats">Message stats</string>
|
||||||
|
<string name="settings_desc_message_stats">Show token and generation performance details in chat.</string>
|
||||||
|
<string name="settings_item_user_message_tokens">User message tokens</string>
|
||||||
|
<string name="settings_item_assistant_message_tokens">Assistant message tokens</string>
|
||||||
|
<string name="settings_item_tokens_per_second">Tokens per second (TPS)</string>
|
||||||
<string name="chat_agent_status_typing">Agent is typing</string>
|
<string name="chat_agent_status_typing">Agent is typing</string>
|
||||||
<string name="chat_agent_status_thinking">Thinking…</string>
|
<string name="chat_agent_status_thinking">Thinking…</string>
|
||||||
<string name="chat_agent_status_searching">Searching…</string>
|
<string name="chat_agent_status_searching">Searching…</string>
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ class ChatUpdateCommandTest {
|
|||||||
every { AuthManager.getToken() } returns "test-token"
|
every { AuthManager.getToken() } returns "test-token"
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
|
every { AuthManager.isMessageStatsEnabled() } returns false
|
||||||
|
every { AuthManager.isUserMessageTokensEnabled() } returns true
|
||||||
|
every { AuthManager.isAssistantMessageTokensEnabled() } returns true
|
||||||
|
every { AuthManager.isTokensPerSecondEnabled() } returns true
|
||||||
every { AuthManager.isAutoReconnect() } returns false
|
every { AuthManager.isAutoReconnect() } returns false
|
||||||
every { AuthManager.isRestoreLastSession() } returns false
|
every { AuthManager.isRestoreLastSession() } returns false
|
||||||
every { AuthManager.getLastOpenedSessionId() } returns null
|
every { AuthManager.getLastOpenedSessionId() } returns null
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ class ChatViewModelTest {
|
|||||||
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
|
every { AuthManager.isMessageStatsEnabled() } returns false
|
||||||
|
every { AuthManager.isUserMessageTokensEnabled() } returns true
|
||||||
|
every { AuthManager.isAssistantMessageTokensEnabled() } returns true
|
||||||
|
every { AuthManager.isTokensPerSecondEnabled() } returns true
|
||||||
every { AuthManager.isAutoReconnect() } returns false
|
every { AuthManager.isAutoReconnect() } returns false
|
||||||
every { AuthManager.isRestoreLastSession() } returns false
|
every { AuthManager.isRestoreLastSession() } returns false
|
||||||
every { AuthManager.getLastOpenedSessionId() } returns null
|
every { AuthManager.getLastOpenedSessionId() } returns null
|
||||||
@@ -4140,13 +4144,16 @@ class ChatViewModelTest {
|
|||||||
fun testRefreshSettings_updatesUiState() =
|
fun testRefreshSettings_updatesUiState() =
|
||||||
runTest {
|
runTest {
|
||||||
// Given the default setup, init{} already calls refreshSettings() once,
|
// Given the default setup, init{} already calls refreshSettings() once,
|
||||||
// so the initial state reflects the setUp defaults (typingEffectEnabled=true,
|
// so the initial state reflects the setUp defaults.
|
||||||
// typingEffectDelayMs=30).
|
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
with(viewModel.uiState.value) {
|
with(viewModel.uiState.value) {
|
||||||
assertTrue(typingEffectEnabled)
|
assertTrue(typingEffectEnabled)
|
||||||
assertEquals(30, typingEffectDelayMs)
|
assertEquals(30, typingEffectDelayMs)
|
||||||
|
assertFalse(messageStatsEnabled)
|
||||||
|
assertTrue(showUserMessageTokens)
|
||||||
|
assertTrue(showAssistantMessageTokens)
|
||||||
|
assertTrue(showTokensPerSecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
// When settings change after construction and refreshSettings() is re-invoked,
|
// When settings change after construction and refreshSettings() is re-invoked,
|
||||||
@@ -4154,6 +4161,10 @@ class ChatViewModelTest {
|
|||||||
// re-reads AuthManager live (the real regression scenario).
|
// re-reads AuthManager live (the real regression scenario).
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns false
|
every { AuthManager.isTypingEffectEnabled() } returns false
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 50
|
every { AuthManager.getTypingEffectDelayMs() } returns 50
|
||||||
|
every { AuthManager.isMessageStatsEnabled() } returns true
|
||||||
|
every { AuthManager.isUserMessageTokensEnabled() } returns false
|
||||||
|
every { AuthManager.isAssistantMessageTokensEnabled() } returns false
|
||||||
|
every { AuthManager.isTokensPerSecondEnabled() } returns false
|
||||||
viewModel.refreshSettings()
|
viewModel.refreshSettings()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
@@ -4161,6 +4172,10 @@ class ChatViewModelTest {
|
|||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
assertFalse(state.typingEffectEnabled)
|
assertFalse(state.typingEffectEnabled)
|
||||||
assertEquals(50, state.typingEffectDelayMs)
|
assertEquals(50, state.typingEffectDelayMs)
|
||||||
|
assertTrue(state.messageStatsEnabled)
|
||||||
|
assertFalse(state.showUserMessageTokens)
|
||||||
|
assertFalse(state.showAssistantMessageTokens)
|
||||||
|
assertFalse(state.showTokensPerSecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ class SlashCommandDispatchRpcTest {
|
|||||||
every { AuthManager.getToken() } returns "test-token"
|
every { AuthManager.getToken() } returns "test-token"
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
|
every { AuthManager.isMessageStatsEnabled() } returns false
|
||||||
|
every { AuthManager.isUserMessageTokensEnabled() } returns true
|
||||||
|
every { AuthManager.isAssistantMessageTokensEnabled() } returns true
|
||||||
|
every { AuthManager.isTokensPerSecondEnabled() } returns true
|
||||||
every { AuthManager.isAutoReconnect() } returns false
|
every { AuthManager.isAutoReconnect() } returns false
|
||||||
every { AuthManager.isRestoreLastSession() } returns false
|
every { AuthManager.isRestoreLastSession() } returns false
|
||||||
every { AuthManager.getLastOpenedSessionId() } returns null
|
every { AuthManager.getLastOpenedSessionId() } returns null
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
every { AuthManager.getChatFontScale() } returns 1.0f
|
every { AuthManager.getChatFontScale() } returns 1.0f
|
||||||
|
every { AuthManager.isMessageStatsEnabled() } returns false
|
||||||
|
every { AuthManager.isUserMessageTokensEnabled() } returns true
|
||||||
|
every { AuthManager.isAssistantMessageTokensEnabled() } returns true
|
||||||
|
every { AuthManager.isTokensPerSecondEnabled() } returns true
|
||||||
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
||||||
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
||||||
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
||||||
@@ -85,6 +89,10 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.setTypingEffectEnabled(any()) } returns Unit
|
every { AuthManager.setTypingEffectEnabled(any()) } returns Unit
|
||||||
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
|
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
|
||||||
every { AuthManager.setChatFontScale(any()) } returns Unit
|
every { AuthManager.setChatFontScale(any()) } returns Unit
|
||||||
|
every { AuthManager.setMessageStatsEnabled(any()) } returns Unit
|
||||||
|
every { AuthManager.setUserMessageTokensEnabled(any()) } returns Unit
|
||||||
|
every { AuthManager.setAssistantMessageTokensEnabled(any()) } returns Unit
|
||||||
|
every { AuthManager.setTokensPerSecondEnabled(any()) } returns Unit
|
||||||
every { AuthManager.setSelectedProfileId(any()) } answers {
|
every { AuthManager.setSelectedProfileId(any()) } answers {
|
||||||
storedSelectedProfileId = firstArg()
|
storedSelectedProfileId = firstArg()
|
||||||
}
|
}
|
||||||
@@ -170,6 +178,46 @@ class SettingsViewModelTest {
|
|||||||
verify { AuthManager.setChatFontScale(1.15f) }
|
verify { AuthManager.setChatFontScale(1.15f) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMessageStats_loadsAndTogglesIndependently() =
|
||||||
|
runTest {
|
||||||
|
every { AuthManager.isMessageStatsEnabled() } returns false
|
||||||
|
every { AuthManager.isUserMessageTokensEnabled() } returns false
|
||||||
|
every { AuthManager.isAssistantMessageTokensEnabled() } returns true
|
||||||
|
every { AuthManager.isTokensPerSecondEnabled() } returns false
|
||||||
|
|
||||||
|
val viewModel = SettingsViewModel(ioDispatcher = testDispatcher)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
assertEquals(false, viewModel.uiState.value.messageStatsEnabled)
|
||||||
|
assertEquals(false, viewModel.uiState.value.showUserMessageTokens)
|
||||||
|
assertEquals(true, viewModel.uiState.value.showAssistantMessageTokens)
|
||||||
|
assertEquals(false, viewModel.uiState.value.showTokensPerSecond)
|
||||||
|
|
||||||
|
viewModel.onMessageStatsEnabledChange(true)
|
||||||
|
assertEquals(true, viewModel.uiState.value.messageStatsEnabled)
|
||||||
|
verify { AuthManager.setMessageStatsEnabled(true) }
|
||||||
|
verify(exactly = 0) { AuthManager.setUserMessageTokensEnabled(any()) }
|
||||||
|
verify(exactly = 0) { AuthManager.setAssistantMessageTokensEnabled(any()) }
|
||||||
|
verify(exactly = 0) { AuthManager.setTokensPerSecondEnabled(any()) }
|
||||||
|
|
||||||
|
viewModel.onUserMessageTokensChange(true)
|
||||||
|
viewModel.onAssistantMessageTokensChange(false)
|
||||||
|
viewModel.onTokensPerSecondChange(true)
|
||||||
|
assertEquals(true, viewModel.uiState.value.showUserMessageTokens)
|
||||||
|
assertEquals(false, viewModel.uiState.value.showAssistantMessageTokens)
|
||||||
|
assertEquals(true, viewModel.uiState.value.showTokensPerSecond)
|
||||||
|
verify { AuthManager.setUserMessageTokensEnabled(true) }
|
||||||
|
verify { AuthManager.setAssistantMessageTokensEnabled(false) }
|
||||||
|
verify { AuthManager.setTokensPerSecondEnabled(true) }
|
||||||
|
|
||||||
|
viewModel.onMessageStatsEnabledChange(false)
|
||||||
|
assertEquals(false, viewModel.uiState.value.messageStatsEnabled)
|
||||||
|
assertEquals(true, viewModel.uiState.value.showUserMessageTokens)
|
||||||
|
assertEquals(false, viewModel.uiState.value.showAssistantMessageTokens)
|
||||||
|
assertEquals(true, viewModel.uiState.value.showTokensPerSecond)
|
||||||
|
verify(exactly = 2) { AuthManager.setMessageStatsEnabled(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testLoadSettings_noSelectedProfile_renameEmpty() {
|
fun testLoadSettings_noSelectedProfile_renameEmpty() {
|
||||||
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
||||||
|
|||||||
Reference in new issue
Block a user