feature: implement Conversation screen's bottom app bar
This commit is contained in:
parent
d3011d48e6
commit
0bcb182d17
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<List<Message>>(emptyList())
|
||||
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
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue