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.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 ->
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue