feature: implement Conversation screen's bottom app bar

This commit is contained in:
Han Yin 2025-04-21 17:19:45 -07:00
parent d3011d48e6
commit 0bcb182d17
6 changed files with 159 additions and 23 deletions

View File

@ -3,6 +3,7 @@ package com.example.llama.revamp
import android.app.Activity import android.app.Activity
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.llama.cpp.InferenceEngine.State
import android.llama.cpp.isUninterruptible import android.llama.cpp.isUninterruptible
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@ -289,7 +290,16 @@ fun AppContent(
} }
// Conversation screen // Conversation screen
currentRoute.startsWith(AppDestinations.CONVERSATION_ROUTE) -> currentRoute.startsWith(AppDestinations.CONVERSATION_ROUTE) -> {
val modelThinkingOrSpeaking =
engineState is State.ProcessingUserPrompt || engineState is State.Generating
val showStubMessage = {
handleScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = "Stub for now, let me know if you want it done :)"
))
}
ScaffoldConfig( ScaffoldConfig(
topBarConfig = TopBarConfig.Performance( topBarConfig = TopBarConfig.Performance(
title = "Chat", title = "Chat",
@ -298,8 +308,17 @@ fun AppContent(
}, },
memoryMetrics = memoryUsage, memoryMetrics = memoryUsage,
temperatureInfo = Pair(temperatureInfo, useFahrenheit) temperatureInfo = Pair(temperatureInfo, useFahrenheit)
),
bottomBarConfig = BottomBarConfig.Conversation(
isEnabled = !modelThinkingOrSpeaking,
textFieldState = conversationViewModel.inputFieldState,
onSendClick = conversationViewModel::sendMessage,
onAttachPhotoClick = showStubMessage,
onAttachFileClick = showStubMessage,
onAudioInputClick = showStubMessage,
) )
) )
}
// Settings screen // Settings screen
currentRoute == AppDestinations.SETTINGS_GENERAL_ROUTE -> currentRoute == AppDestinations.SETTINGS_GENERAL_ROUTE ->

View File

@ -99,6 +99,17 @@ fun AppScaffold(
onShare = config.onShare onShare = config.onShare
) )
} }
is BottomBarConfig.Conversation -> {
ConversationBottomBar(
isReady = config.isEnabled,
textFieldState = config.textFieldState,
onSendClick = config.onSendClick,
onAttachPhotoClick = config.onAttachPhotoClick,
onAttachFileClick = config.onAttachFileClick,
onAudioInputClick = config.onAudioInputClick,
)
}
} }
} }

View File

@ -5,11 +5,18 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.clearText
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.automirrored.outlined.Backspace import androidx.compose.material.icons.automirrored.outlined.Backspace
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
@ -19,17 +26,22 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SearchOff import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.AddPhotoAlternate
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FilterAltOff import androidx.compose.material.icons.outlined.FilterAltOff
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@ -37,13 +49,19 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.llama.R import com.example.llama.R
import com.example.llama.revamp.APP_NAME
import com.example.llama.revamp.data.model.ModelFilter import com.example.llama.revamp.data.model.ModelFilter
import com.example.llama.revamp.data.model.ModelInfo import com.example.llama.revamp.data.model.ModelInfo
import com.example.llama.revamp.data.model.ModelSortOrder import com.example.llama.revamp.data.model.ModelSortOrder
@ -134,7 +152,14 @@ sealed class BottomBarConfig {
val onShare: () -> Unit, val onShare: () -> Unit,
) : BottomBarConfig() ) : BottomBarConfig()
// TODO-han.yin: add bottom bar config for Conversation Screen! data class Conversation(
val isEnabled: Boolean,
val textFieldState: TextFieldState,
val onSendClick: () -> Unit,
val onAttachPhotoClick: () -> Unit,
val onAttachFileClick: () -> Unit,
val onAudioInputClick: () -> Unit,
) : BottomBarConfig()
} }
@Composable @Composable
@ -498,3 +523,90 @@ fun BenchmarkBottomBar(
} }
) )
} }
@Composable
fun ConversationBottomBar(
textFieldState: TextFieldState,
isReady: Boolean,
onSendClick: () -> Unit,
onAttachPhotoClick: () -> Unit,
onAttachFileClick: () -> Unit,
onAudioInputClick: () -> Unit,
) {
val placeholder = if (isReady) "Message $APP_NAME..." else "Please wait for $APP_NAME to finish"
Column(
modifier = Modifier.fillMaxWidth()
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = BottomAppBarDefaults.containerColor,
tonalElevation = BottomAppBarDefaults.ContainerElevation,
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp)
) {
Box(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, end = 16.dp),
) {
OutlinedTextField(
state = textFieldState,
modifier = Modifier.fillMaxWidth().padding(end = 8.dp),
enabled = isReady,
placeholder = { Text(placeholder) },
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 5),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceDim,
),
shape = RoundedCornerShape(16.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
onKeyboardAction = { if (isReady) { onSendClick() } }
)
}
}
BottomAppBar(
actions = {
IconButton(onClick = onAttachPhotoClick) {
Icon(
imageVector = Icons.Outlined.AddPhotoAlternate,
contentDescription = "Attach a photo",
)
}
IconButton(onClick = onAttachFileClick) {
Icon(
imageVector = Icons.Outlined.AttachFile,
contentDescription = "Attach a file",
)
}
IconButton(onClick = onAudioInputClick) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = "Input with voice",
)
}
},
floatingActionButton = {
FloatingActionButton(
onClick = { if (isReady) { onSendClick() } },
containerColor = MaterialTheme.colorScheme.primary
) {
if (isReady) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send message",
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeCap = StrokeCap.Round,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
)
}
}

View File

@ -88,7 +88,6 @@ fun ConversationScreen(
val selectedModel by viewModel.selectedModel.collectAsState() val selectedModel by viewModel.selectedModel.collectAsState()
val unloadDialogState by viewModel.unloadModelState.collectAsState() val unloadDialogState by viewModel.unloadModelState.collectAsState()
val isProcessing = engineState is State.ProcessingUserPrompt
val isGenerating = engineState is State.Generating val isGenerating = engineState is State.Generating
// UI states // UI states
@ -96,7 +95,6 @@ fun ConversationScreen(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var isModelCardExpanded by remember { mutableStateOf(false) } var isModelCardExpanded by remember { mutableStateOf(false) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
var inputText by remember { mutableStateOf("") }
// Auto-scroll to bottom when messages change or when typing // Auto-scroll to bottom when messages change or when typing
val shouldScrollToBottom by remember(messages.size, isGenerating) { val shouldScrollToBottom by remember(messages.size, isGenerating) {
@ -161,19 +159,6 @@ fun ConversationScreen(
listState = listState, listState = listState,
) )
} }
// Input area
ConversationInputField(
value = inputText,
onValueChange = { inputText = it },
onSendClick = {
if (inputText.isNotBlank()) {
viewModel.sendMessage(inputText)
inputText = ""
}
},
isEnabled = !isProcessing && !isGenerating
)
} }
// Unload confirmation dialog // Unload confirmation dialog

View File

@ -1,5 +1,7 @@
package com.example.llama.revamp.viewmodel package com.example.llama.revamp.viewmodel
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.llama.revamp.engine.ConversationService import com.example.llama.revamp.engine.ConversationService
import com.example.llama.revamp.engine.GenerationUpdate import com.example.llama.revamp.engine.GenerationUpdate
@ -22,21 +24,25 @@ import javax.inject.Inject
class ConversationViewModel @Inject constructor( class ConversationViewModel @Inject constructor(
private val conversationService: ConversationService private val conversationService: ConversationService
) : ModelUnloadingViewModel(conversationService) { ) : ModelUnloadingViewModel(conversationService) {
// Data
val selectedModel = conversationService.currentSelectedModel val selectedModel = conversationService.currentSelectedModel
val systemPrompt = conversationService.systemPrompt val systemPrompt = conversationService.systemPrompt
// Messages in conversation // Messages state
private val _messages = MutableStateFlow<List<Message>>(emptyList()) private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages.asStateFlow() val messages: StateFlow<List<Message>> = _messages.asStateFlow()
// Keep track of token generation job // Input text field state
val inputFieldState = TextFieldState()
// Token generation job
private var tokenCollectionJob: Job? = null private var tokenCollectionJob: Job? = null
/** /**
* Send a message with the provided content * Send a message with the provided content
*/ */
fun sendMessage(content: String) { fun sendMessage() {
val content = inputFieldState.text.toString()
if (content.isBlank()) return if (content.isBlank()) return
// Cancel ongoing collection // Cancel ongoing collection
@ -56,6 +62,9 @@ class ConversationViewModel @Inject constructor(
) )
_messages.value = _messages.value + assistantMessage _messages.value = _messages.value + assistantMessage
// Clear input field
inputFieldState.clearText()
// Collect response // Collect response
tokenCollectionJob = viewModelScope.launch { tokenCollectionJob = viewModelScope.launch {
try { try {

View File

@ -23,7 +23,7 @@ serialization = "1.8.1"
compose-bom = "2025.03.01" compose-bom = "2025.03.01"
compose-foundation = "1.7.8" compose-foundation = "1.7.8"
compose-material-icons = "1.7.8" compose-material-icons = "1.7.8"
compose-material3 = "1.3.2" compose-material3 = "1.4.0-alpha12"
compose-ui = "1.7.8" compose-ui = "1.7.8"
# Accompanist # Accompanist