From 0bcb182d17287b8e995004bdca111cf13e2c73ac Mon Sep 17 00:00:00 2001 From: Han Yin Date: Mon, 21 Apr 2025 17:19:45 -0700 Subject: [PATCH] feature: implement Conversation screen's bottom app bar --- .../com/example/llama/revamp/MainActivity.kt | 23 +++- .../llama/revamp/ui/scaffold/AppScaffold.kt | 11 ++ .../llama/revamp/ui/scaffold/BottomAppBars.kt | 114 +++++++++++++++++- .../revamp/ui/screens/ConversationScreen.kt | 15 --- .../revamp/viewmodel/ConversationViewModel.kt | 17 ++- .../llama.android/gradle/libs.versions.toml | 2 +- 6 files changed, 159 insertions(+), 23 deletions(-) diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt index ef7606b664..e866367a70 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt @@ -3,6 +3,7 @@ package com.example.llama.revamp import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent +import android.llama.cpp.InferenceEngine.State import android.llama.cpp.isUninterruptible import android.os.Bundle import androidx.activity.ComponentActivity @@ -289,17 +290,35 @@ fun AppContent( } // 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( topBarConfig = TopBarConfig.Performance( title = "Chat", navigationIcon = NavigationIcon.Back { - conversationViewModel.onBackPressed { navigationActions.navigateUp() } + conversationViewModel.onBackPressed { navigationActions.navigateUp() } }, memoryMetrics = memoryUsage, temperatureInfo = Pair(temperatureInfo, useFahrenheit) + ), + bottomBarConfig = BottomBarConfig.Conversation( + isEnabled = !modelThinkingOrSpeaking, + textFieldState = conversationViewModel.inputFieldState, + onSendClick = conversationViewModel::sendMessage, + onAttachPhotoClick = showStubMessage, + onAttachFileClick = showStubMessage, + onAudioInputClick = showStubMessage, ) ) + } // Settings screen currentRoute == AppDestinations.SETTINGS_GENERAL_ROUTE -> diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/AppScaffold.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/AppScaffold.kt index 5e4587edd6..cbdc13d632 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/AppScaffold.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/AppScaffold.kt @@ -99,6 +99,17 @@ fun AppScaffold( 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, + ) + } } } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/BottomAppBars.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/BottomAppBars.kt index fdf80a9616..a98d2d027c 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/BottomAppBars.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/BottomAppBars.kt @@ -5,11 +5,18 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn 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.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.clearText 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.outlined.Backspace 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.FilterAlt 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.Replay import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SearchOff import androidx.compose.material.icons.filled.SelectAll 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.FilterAlt import androidx.compose.material.icons.outlined.FilterAltOff import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FloatingActionButton @@ -37,13 +49,19 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp 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.ModelInfo import com.example.llama.revamp.data.model.ModelSortOrder @@ -134,7 +152,14 @@ sealed class BottomBarConfig { val onShare: () -> Unit, ) : 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 @@ -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, + ) + } + } + } + ) + } +} diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ConversationScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ConversationScreen.kt index 84d447f896..62e7efccd7 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ConversationScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ConversationScreen.kt @@ -88,7 +88,6 @@ fun ConversationScreen( val selectedModel by viewModel.selectedModel.collectAsState() val unloadDialogState by viewModel.unloadModelState.collectAsState() - val isProcessing = engineState is State.ProcessingUserPrompt val isGenerating = engineState is State.Generating // UI states @@ -96,7 +95,6 @@ fun ConversationScreen( val coroutineScope = rememberCoroutineScope() var isModelCardExpanded by remember { mutableStateOf(false) } val listState = rememberLazyListState() - var inputText by remember { mutableStateOf("") } // Auto-scroll to bottom when messages change or when typing val shouldScrollToBottom by remember(messages.size, isGenerating) { @@ -161,19 +159,6 @@ fun ConversationScreen( listState = listState, ) } - - // Input area - ConversationInputField( - value = inputText, - onValueChange = { inputText = it }, - onSendClick = { - if (inputText.isNotBlank()) { - viewModel.sendMessage(inputText) - inputText = "" - } - }, - isEnabled = !isProcessing && !isGenerating - ) } // Unload confirmation dialog diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ConversationViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ConversationViewModel.kt index 5063ca50e7..99a2ec86e0 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ConversationViewModel.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ConversationViewModel.kt @@ -1,5 +1,7 @@ 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 com.example.llama.revamp.engine.ConversationService import com.example.llama.revamp.engine.GenerationUpdate @@ -22,21 +24,25 @@ import javax.inject.Inject class ConversationViewModel @Inject constructor( private val conversationService: ConversationService ) : ModelUnloadingViewModel(conversationService) { - + // Data val selectedModel = conversationService.currentSelectedModel val systemPrompt = conversationService.systemPrompt - // Messages in conversation + // Messages state private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages.asStateFlow() - // Keep track of token generation job + // Input text field state + val inputFieldState = TextFieldState() + + // Token generation job private var tokenCollectionJob: Job? = null /** * Send a message with the provided content */ - fun sendMessage(content: String) { + fun sendMessage() { + val content = inputFieldState.text.toString() if (content.isBlank()) return // Cancel ongoing collection @@ -56,6 +62,9 @@ class ConversationViewModel @Inject constructor( ) _messages.value = _messages.value + assistantMessage + // Clear input field + inputFieldState.clearText() + // Collect response tokenCollectionJob = viewModelScope.launch { try { diff --git a/examples/llama.android/gradle/libs.versions.toml b/examples/llama.android/gradle/libs.versions.toml index 0e2a107773..8562d10dad 100644 --- a/examples/llama.android/gradle/libs.versions.toml +++ b/examples/llama.android/gradle/libs.versions.toml @@ -23,7 +23,7 @@ serialization = "1.8.1" compose-bom = "2025.03.01" compose-foundation = "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" # Accompanist