UI: add show/hide stats control to conversation screen's assistant message bubble; fix placeholder
This commit is contained in:
parent
8bd9615e6b
commit
027c68db64
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Row(
|
if (isGenerating) {
|
||||||
modifier = Modifier
|
Row(
|
||||||
.height(20.dp)
|
modifier = Modifier.height(20.dp).padding(top = 4.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) {
|
}
|
||||||
// Show metrics when message is complete
|
} else {
|
||||||
Text(
|
// Show metrics when message is complete
|
||||||
text = metrics,
|
metrics?.let {
|
||||||
style = MaterialTheme.typography.labelSmall,
|
ExpandableTokenMetricsBubble(metrics)
|
||||||
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue