UI: add show/hide stats control to conversation screen's assistant message bubble; fix placeholder

This commit is contained in:
Han Yin 2025-08-29 18:56:43 -07:00
parent 8bd9615e6b
commit 027c68db64
5 changed files with 152 additions and 30 deletions

View File

@ -107,6 +107,7 @@ data class TokenMetrics(
val tokensCount: Int, val tokensCount: Int,
val ttftMs: Long, val ttftMs: Long,
val tpsMs: Float, val tpsMs: Float,
val duration: Long,
) { ) {
val text: String val text: String
get() = "Tokens: $tokensCount, TTFT: ${ttftMs}ms, TPS: ${"%.1f".format(tpsMs)}" get() = "Tokens: $tokensCount, TTFT: ${ttftMs}ms, TPS: ${"%.1f".format(tpsMs)}"
@ -270,7 +271,8 @@ internal class InferenceServiceImpl @Inject internal constructor(
return TokenMetrics( return TokenMetrics(
tokensCount = tokenCount, tokensCount = tokenCount,
ttftMs = if (firstTokenTime > 0) firstTokenTime - generationStartTime else 0L, ttftMs = if (firstTokenTime > 0) firstTokenTime - generationStartTime else 0L,
tpsMs = calculateTPS(tokenCount, totalTimeMs) tpsMs = calculateTPS(tokenCount, totalTimeMs),
duration = totalTimeMs,
) )
} }

View File

@ -45,7 +45,7 @@ fun ConversationBottomBar(
onAttachFileClick: (() -> Unit)?, onAttachFileClick: (() -> Unit)?,
onAudioInputClick: (() -> Unit)?, onAudioInputClick: (() -> Unit)?,
) { ) {
val placeholder = if (isReady) "Message ${APP_NAME}..." else "Please wait for ${APP_NAME} to finish" val placeholder = if (isReady) "Type your message here" else "AI is generating the response..."
Column( Column(
modifier = Modifier.Companion.fillMaxWidth() modifier = Modifier.Companion.fillMaxWidth()

View File

@ -27,11 +27,16 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TonalToggleButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -55,18 +60,22 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import com.example.llama.data.model.ModelInfo import com.example.llama.data.model.ModelInfo
import com.example.llama.engine.ModelLoadingMetrics import com.example.llama.engine.ModelLoadingMetrics
import com.example.llama.engine.TokenMetrics
import com.example.llama.ui.components.ModelCardContentArchitectureRow import com.example.llama.ui.components.ModelCardContentArchitectureRow
import com.example.llama.ui.components.ModelCardContentContextRow import com.example.llama.ui.components.ModelCardContentContextRow
import com.example.llama.ui.components.ModelCardContentField import com.example.llama.ui.components.ModelCardContentField
import com.example.llama.ui.components.ModelCardCoreExpandable import com.example.llama.ui.components.ModelCardCoreExpandable
import com.example.llama.ui.components.ModelUnloadDialogHandler import com.example.llama.ui.components.ModelUnloadDialogHandler
import com.example.llama.util.formatMilliSeconds import com.example.llama.util.formatMilliSeconds
import com.example.llama.util.formatMilliSecondstructured
import com.example.llama.util.toEnglishName
import com.example.llama.viewmodel.ConversationViewModel import com.example.llama.viewmodel.ConversationViewModel
import com.example.llama.viewmodel.Message import com.example.llama.viewmodel.Message
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale
private const val AUTO_REPOSITION_INTERVAL = 150L private const val AUTO_REPOSITION_INTERVAL = 150L
@ -311,7 +320,7 @@ private fun MessageBubble(message: Message) {
content = message.content, content = message.content,
isThinking = false, isThinking = false,
isGenerating = false, isGenerating = false,
metrics = message.metrics.text metrics = message.metrics
) )
} }
} }
@ -319,9 +328,7 @@ private fun MessageBubble(message: Message) {
@Composable @Composable
private fun UserMessageBubble(content: String, formattedTime: String) { private fun UserMessageBubble(content: String, formattedTime: String) {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.End horizontalAlignment = Alignment.End
) { ) {
// Timestamp above bubble // Timestamp above bubble
@ -332,11 +339,12 @@ private fun UserMessageBubble(content: String, formattedTime: String) {
modifier = Modifier.padding(bottom = 4.dp) modifier = Modifier.padding(bottom = 4.dp)
) )
Row { Row(modifier = Modifier.fillMaxWidth(0.9f)) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Card( Card(
shape = RoundedCornerShape(16.dp, 4.dp, 16.dp, 16.dp), modifier = Modifier,
shape = RoundedCornerShape(16.dp, 2.dp, 16.dp, 16.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer containerColor = MaterialTheme.colorScheme.primaryContainer
), ),
@ -359,7 +367,7 @@ private fun AssistantMessageBubble(
content: String, content: String,
isThinking: Boolean, isThinking: Boolean,
isGenerating: Boolean, isGenerating: Boolean,
metrics: String? = null metrics: TokenMetrics? = null
) { ) {
Row( Row(
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
@ -381,10 +389,7 @@ private fun AssistantMessageBubble(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Column( Column(modifier = Modifier.fillMaxWidth(0.9f)) {
modifier = Modifier
.fillMaxWidth()
) {
// Timestamp above bubble // Timestamp above bubble
if (formattedTime.isNotBlank()) { if (formattedTime.isNotBlank()) {
Text( Text(
@ -396,7 +401,7 @@ private fun AssistantMessageBubble(
} }
Card( Card(
shape = RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp), shape = RoundedCornerShape(2.dp, 16.dp, 16.dp, 16.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant containerColor = MaterialTheme.colorScheme.surfaceVariant
), ),
@ -412,13 +417,11 @@ private fun AssistantMessageBubble(
} }
// Show metrics or generation status below the bubble // Show metrics or generation status below the bubble
if (isGenerating) {
Row( Row(
modifier = Modifier modifier = Modifier.height(20.dp).padding(top = 4.dp),
.height(20.dp)
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (isGenerating) {
PulsatingDots(small = true) PulsatingDots(small = true)
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
@ -428,13 +431,11 @@ private fun AssistantMessageBubble(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} else if (metrics != null) { }
} else {
// Show metrics when message is complete // Show metrics when message is complete
Text( metrics?.let {
text = metrics, ExpandableTokenMetricsBubble(metrics)
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
} }
} }
} }
@ -480,3 +481,82 @@ private fun PulsatingDots(small: Boolean = false) {
} }
} }
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ExpandableTokenMetricsBubble(metrics: TokenMetrics) {
var showMetrics by remember { mutableStateOf(false) }
Column {
TonalToggleButton(
checked = showMetrics,
onCheckedChange = { showMetrics = !showMetrics }
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Default.Timer,
contentDescription = "${if (showMetrics) "Hide" else "Show"} token metrics of this assistant message"
)
Text(
text = "${if (showMetrics) "Hide" else "Show"} stats",
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.labelMedium,
)
}
if (showMetrics) {
Card(
shape = RoundedCornerShape(2.dp, 16.dp, 16.dp, 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp)
) {
val ttft = formatMilliSecondstructured(metrics.ttftMs)
TokenMetricSection("1st Token", ttft.value.toString(), ttft.unit.toEnglishName())
val tps = String.format(Locale.getDefault(), "%.2f", metrics.tpsMs)
TokenMetricSection("Decode speed", tps, "tokens/sec")
val duration = formatMilliSecondstructured(metrics.duration)
TokenMetricSection("Duration", duration.value.toString(), duration.unit.toEnglishName())
}
}
}
}
}
@Composable
private fun TokenMetricSection(metricName: String, metricValue: String, metricUnit: String) {
Column {
Text(
text = metricName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Normal,
)
Text(
modifier = Modifier.padding(top = 2.dp),
text = metricValue,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Light,
fontStyle = FontStyle.Italic
)
Text(
modifier = Modifier.padding(top = 2.dp),
text = metricUnit,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
fontWeight = FontWeight.Light,
fontStyle = FontStyle.Italic
)
}
}

View File

@ -1,6 +1,21 @@
package com.example.llama.util package com.example.llama.util
import java.util.concurrent.TimeUnit
import java.util.Locale import java.util.Locale
import kotlin.math.round
/**
* Maps [TimeUnit] to English names, with plural form support
*/
fun TimeUnit.toEnglishName(plural: Boolean = false): String = when (this) {
TimeUnit.NANOSECONDS -> if (plural) "nanoseconds" else "nanosecond"
TimeUnit.MICROSECONDS -> if (plural) "microseconds" else "microsecond"
TimeUnit.MILLISECONDS -> if (plural) "milliseconds" else "millisecond"
TimeUnit.SECONDS -> if (plural) "seconds" else "second"
TimeUnit.MINUTES -> if (plural) "minutes" else "minute"
TimeUnit.HOURS -> if (plural) "hours" else "hour"
TimeUnit.DAYS -> if (plural) "days" else "day"
}
/** /**
* Formats milliseconds into a human-readable time string * Formats milliseconds into a human-readable time string
@ -18,6 +33,31 @@ fun formatMilliSeconds(millis: Long): String {
} }
} }
data class DurationValue(
val value: Double,
val unit: TimeUnit
)
/**
* Converts milliseconds into a structured DurationValue.
*
* Rules:
* - < 100 seconds -> show in SECONDS
* - < 100 minutes -> show in MINUTES
* - < 100 hours -> show in HOURS
*/
fun formatMilliSecondstructured(millis: Long): DurationValue {
val seconds = millis / 1000.0
return when {
seconds < 100 -> DurationValue(round2(seconds), TimeUnit.SECONDS)
seconds < 100 * 60 -> DurationValue(round2(seconds / 60.0), TimeUnit.MINUTES)
else -> DurationValue(round2(seconds / 3600.0), TimeUnit.HOURS)
}
}
private fun round2(v: Double): Double = round(v * 100) / 100
/** /**
* Convert bytes into human readable sizes * Convert bytes into human readable sizes
*/ */

View File

@ -62,14 +62,14 @@ class ConversationViewModel @Inject constructor(
content = content, content = content,
timestamp = System.currentTimeMillis() timestamp = System.currentTimeMillis()
) )
_messages.value = _messages.value + userMessage _messages.value += userMessage
// Add placeholder for assistant response // Add placeholder for assistant response
val assistantMessage = Message.Assistant.Ongoing( val assistantMessage = Message.Assistant.Ongoing(
content = "", content = "",
timestamp = System.currentTimeMillis() timestamp = System.currentTimeMillis()
) )
_messages.value = _messages.value + assistantMessage _messages.value += assistantMessage
// Clear input field // Clear input field
inputFieldState.clearText() inputFieldState.clearText()