UI: show RAM warning if model too large

This commit is contained in:
Han Yin 2025-07-10 20:00:35 -07:00
parent a5a54375a2
commit 712bc745df
7 changed files with 176 additions and 36 deletions

View File

@ -193,7 +193,7 @@ fun AppContent(
val showSortMenu by modelSelectionViewModel.showSortMenu.collectAsState() val showSortMenu by modelSelectionViewModel.showSortMenu.collectAsState()
val activeFilters by modelSelectionViewModel.activeFilters.collectAsState() val activeFilters by modelSelectionViewModel.activeFilters.collectAsState()
val showFilterMenu by modelSelectionViewModel.showFilterMenu.collectAsState() val showFilterMenu by modelSelectionViewModel.showFilterMenu.collectAsState()
val preselectedModel by modelSelectionViewModel.preselectedModel.collectAsState() val preselection by modelSelectionViewModel.preselection.collectAsState()
ScaffoldConfig( ScaffoldConfig(
topBarConfig = topBarConfig =
@ -230,12 +230,13 @@ fun AppContent(
toggleMenu = modelSelectionViewModel::toggleFilterMenu toggleMenu = modelSelectionViewModel::toggleFilterMenu
), ),
runAction = BottomBarConfig.ModelSelection.RunActionConfig( runAction = BottomBarConfig.ModelSelection.RunActionConfig(
selectedModel = preselectedModel, preselection = preselection,
onRun = { model -> onClickRun = { preselection ->
modelSelectionViewModel.confirmSelectedModel(model) if (modelSelectionViewModel.selectModel(preselection)) {
navigationActions.navigateToModelLoading() navigationActions.navigateToModelLoading()
modelSelectionViewModel.toggleSearchState(false) modelSelectionViewModel.toggleSearchState(false)
} }
}
) )
) )
) )
@ -437,6 +438,12 @@ fun AppContent(
onManageModelsClicked = { onManageModelsClicked = {
navigationActions.navigateToModelsManagement() navigationActions.navigateToModelsManagement()
}, },
onConfirmSelection = { modelInfo, ramWarning ->
if (modelSelectionViewModel.confirmSelectedModel(modelInfo, ramWarning)) {
navigationActions.navigateToModelLoading()
modelSelectionViewModel.toggleSearchState(false)
}
},
viewModel = modelSelectionViewModel viewModel = modelSelectionViewModel
) )
} }

View File

@ -3,7 +3,6 @@ package com.example.llama.ui.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -27,13 +26,14 @@ data class InfoAction(
@Composable @Composable
fun InfoView( fun InfoView(
modifier: Modifier = Modifier,
title: String, title: String,
icon: ImageVector, icon: ImageVector,
message: String? = null, message: String? = null,
action: InfoAction? = null action: InfoAction? = null
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
@ -62,10 +62,9 @@ fun InfoView(
) )
} }
action?.let {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
action?.let {
Button(onClick = action.onAction) { Button(onClick = action.onAction) {
Icon( Icon(
imageVector = action.icon, imageVector = action.icon,

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.text.input.TextFieldState
import com.example.llama.data.model.ModelFilter import com.example.llama.data.model.ModelFilter
import com.example.llama.data.model.ModelInfo import com.example.llama.data.model.ModelInfo
import com.example.llama.data.model.ModelSortOrder import com.example.llama.data.model.ModelSortOrder
import com.example.llama.viewmodel.Preselection
/** /**
* [BottomAppBar] configurations * [BottomAppBar] configurations
@ -42,8 +43,8 @@ sealed class BottomBarConfig {
) )
data class RunActionConfig( data class RunActionConfig(
val selectedModel: ModelInfo?, val preselection: Preselection?,
val onRun: (ModelInfo) -> Unit val onClickRun: (Preselection) -> Unit,
) )
} }

View File

@ -173,12 +173,12 @@ fun ModelSelectionBottomBar(
floatingActionButton = { floatingActionButton = {
// Only show FAB if a model is selected // Only show FAB if a model is selected
AnimatedVisibility( AnimatedVisibility(
visible = runAction.selectedModel != null, visible = runAction.preselection != null,
enter = scaleIn() + fadeIn(), enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut() exit = scaleOut() + fadeOut()
) { ) {
FloatingActionButton( FloatingActionButton(
onClick = { runAction.selectedModel?.let { runAction.onRun(it) } }, onClick = { runAction.preselection?.let { runAction.onClickRun(it) } },
containerColor = MaterialTheme.colorScheme.primary containerColor = MaterialTheme.colorScheme.primary
) { ) {
Icon( Icon(

View File

@ -20,6 +20,8 @@ import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
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.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DockedSearchBar import androidx.compose.material3.DockedSearchBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -27,6 +29,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -40,20 +43,24 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.llama.data.model.ModelInfo
import com.example.llama.ui.components.InfoAction import com.example.llama.ui.components.InfoAction
import com.example.llama.ui.components.InfoView import com.example.llama.ui.components.InfoView
import com.example.llama.ui.components.ModelCardFullExpandable import com.example.llama.ui.components.ModelCardFullExpandable
import com.example.llama.util.formatFileByteSize
import com.example.llama.viewmodel.ModelSelectionViewModel import com.example.llama.viewmodel.ModelSelectionViewModel
import com.example.llama.viewmodel.Preselection.RamWarning
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ModelSelectionScreen( fun ModelSelectionScreen(
onManageModelsClicked: () -> Unit, onManageModelsClicked: () -> Unit,
onConfirmSelection: (ModelInfo, RamWarning) -> Unit,
viewModel: ModelSelectionViewModel, viewModel: ModelSelectionViewModel,
) { ) {
// Data: models // Data: models
val filteredModels by viewModel.filteredModels.collectAsState() val filteredModels by viewModel.filteredModels.collectAsState()
val preselectedModel by viewModel.preselectedModel.collectAsState() val preselection by viewModel.preselection.collectAsState()
// Query states // Query states
val textFieldState = viewModel.searchFieldState val textFieldState = viewModel.searchFieldState
@ -83,7 +90,7 @@ fun ModelSelectionScreen(
} }
// Handle back button press // Handle back button press
BackHandler(preselectedModel != null || isSearchActive) { BackHandler(preselection != null || isSearchActive) {
if (isSearchActive) { if (isSearchActive) {
viewModel.toggleSearchState(false) viewModel.toggleSearchState(false)
} else { } else {
@ -143,7 +150,7 @@ fun ModelSelectionScreen(
items(items = queryResults, key = { it.id }) { model -> items(items = queryResults, key = { it.id }) { model ->
ModelCardFullExpandable( ModelCardFullExpandable(
model = model, model = model,
isSelected = if (model == preselectedModel) true else null, isSelected = if (model == preselection?.modelInfo) true else null,
onSelected = { selected -> onSelected = { selected ->
if (selected) { if (selected) {
toggleSearchFocusAndIme(false) toggleSearchFocusAndIme(false)
@ -152,7 +159,7 @@ fun ModelSelectionScreen(
toggleSearchFocusAndIme(true) toggleSearchFocusAndIme(true)
} }
}, },
isExpanded = model == preselectedModel, isExpanded = model == preselection?.modelInfo,
onExpanded = { expanded -> onExpanded = { expanded ->
viewModel.preselectModel(model, expanded) viewModel.preselectModel(model, expanded)
toggleSearchFocusAndIme(!expanded) toggleSearchFocusAndIme(!expanded)
@ -176,11 +183,11 @@ fun ModelSelectionScreen(
items(items = filteredModels, key = { it.id }) { model -> items(items = filteredModels, key = { it.id }) { model ->
ModelCardFullExpandable( ModelCardFullExpandable(
model = model, model = model,
isSelected = if (model == preselectedModel) true else null, isSelected = if (model == preselection?.modelInfo) true else null,
onSelected = { selected -> onSelected = { selected ->
if (!selected) viewModel.resetSelection() if (!selected) viewModel.resetSelection()
}, },
isExpanded = model == preselectedModel, isExpanded = model == preselection?.modelInfo,
onExpanded = { expanded -> onExpanded = { expanded ->
viewModel.preselectModel(model, expanded) viewModel.preselectModel(model, expanded)
} }
@ -189,6 +196,19 @@ fun ModelSelectionScreen(
} }
} }
} }
// Show insufficient RAM warning
preselection?.let {
it.ramWarning?.let { warning ->
if (warning.showing) {
RamErrorDialog(
warning,
onDismiss = { viewModel.dismissRamWarning() },
onConfirm = { onConfirmSelection(it.modelInfo, warning) }
)
}
}
}
} }
} }
@ -203,6 +223,7 @@ private fun EmptyModelsView(
else -> "No models match the selected filters" else -> "No models match the selected filters"
} }
InfoView( InfoView(
modifier = Modifier.fillMaxSize(),
title = "No Models Available", title = "No Models Available",
icon = Icons.Default.FolderOpen, icon = Icons.Default.FolderOpen,
message = message, message = message,
@ -253,3 +274,42 @@ private fun EmptySearchResultsView(
} }
} }
} }
@Composable
private fun RamErrorDialog(
ramError: RamWarning,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
val requiredRam = formatFileByteSize(ramError.requiredRam)
val availableRam = formatFileByteSize(ramError.availableRam)
AlertDialog(
text = {
InfoView(
modifier = Modifier.fillMaxWidth(),
title = "Insufficient RAM",
icon = Icons.Default.Warning,
message = "You are trying to run a $requiredRam size model, " +
"but currently there's only $availableRam memory available!",
)
},
containerColor = MaterialTheme.colorScheme.errorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
textContentColor = MaterialTheme.colorScheme.onErrorContainer,
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
text = "Proceed",
color = MaterialTheme.colorScheme.error
)
}
}
)
}

View File

@ -115,6 +115,7 @@ fun ModelsManagementScreen(
else -> "No models match the selected filters" else -> "No models match the selected filters"
} }
InfoView( InfoView(
modifier = Modifier.fillMaxSize(),
title = "No Models Available", title = "No Models Available",
icon = Icons.Default.FolderOpen, icon = Icons.Default.FolderOpen,
message = message, message = message,

View File

@ -13,14 +13,18 @@ import com.example.llama.data.model.queryBy
import com.example.llama.data.model.sortByOrder import com.example.llama.data.model.sortByOrder
import com.example.llama.data.repo.ModelRepository import com.example.llama.data.repo.ModelRepository
import com.example.llama.engine.InferenceService import com.example.llama.engine.InferenceService
import com.example.llama.monitoring.PerformanceMonitor
import com.example.llama.viewmodel.Preselection.RamWarning
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -29,13 +33,14 @@ import javax.inject.Inject
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
@HiltViewModel @HiltViewModel
class ModelSelectionViewModel @Inject constructor( class ModelSelectionViewModel @Inject constructor(
modelRepository: ModelRepository,
private val performanceMonitor: PerformanceMonitor,
private val inferenceService: InferenceService, private val inferenceService: InferenceService,
modelRepository: ModelRepository
) : ViewModel() { ) : ViewModel() {
// UI state: search mode // UI state: search mode
private val _isSearchActive = MutableStateFlow(false) private val _isSearchActive = MutableStateFlow(false)
val isSearchActive: StateFlow<Boolean> = _isSearchActive.asStateFlow() val isSearchActive = _isSearchActive.asStateFlow()
fun toggleSearchState(active: Boolean) { fun toggleSearchState(active: Boolean) {
_isSearchActive.value = active _isSearchActive.value = active
@ -50,14 +55,14 @@ class ModelSelectionViewModel @Inject constructor(
// UI state: sort menu // UI state: sort menu
private val _sortOrder = MutableStateFlow(ModelSortOrder.LAST_USED) private val _sortOrder = MutableStateFlow(ModelSortOrder.LAST_USED)
val sortOrder: StateFlow<ModelSortOrder> = _sortOrder.asStateFlow() val sortOrder = _sortOrder.asStateFlow()
fun setSortOrder(order: ModelSortOrder) { fun setSortOrder(order: ModelSortOrder) {
_sortOrder.value = order _sortOrder.value = order
} }
private val _showSortMenu = MutableStateFlow(false) private val _showSortMenu = MutableStateFlow(false)
val showSortMenu: StateFlow<Boolean> = _showSortMenu.asStateFlow() val showSortMenu = _showSortMenu.asStateFlow()
fun toggleSortMenu(visible: Boolean) { fun toggleSortMenu(visible: Boolean) {
_showSortMenu.value = visible _showSortMenu.value = visible
@ -84,7 +89,7 @@ class ModelSelectionViewModel @Inject constructor(
} }
private val _showFilterMenu = MutableStateFlow(false) private val _showFilterMenu = MutableStateFlow(false)
val showFilterMenu: StateFlow<Boolean> = _showFilterMenu.asStateFlow() val showFilterMenu = _showFilterMenu.asStateFlow()
fun toggleFilterMenu(visible: Boolean) { fun toggleFilterMenu(visible: Boolean) {
_showFilterMenu.value = visible _showFilterMenu.value = visible
@ -92,15 +97,34 @@ class ModelSelectionViewModel @Inject constructor(
// Data: filtered & sorted models // Data: filtered & sorted models
private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList()) private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList())
val filteredModels: StateFlow<List<ModelInfo>> = _filteredModels.asStateFlow() val filteredModels = _filteredModels.asStateFlow()
// Data: queried models // Data: queried models
private val _queryResults = MutableStateFlow<List<ModelInfo>>(emptyList()) private val _queryResults = MutableStateFlow<List<ModelInfo>>(emptyList())
val queryResults: StateFlow<List<ModelInfo>> = _queryResults.asStateFlow() val queryResults = _queryResults.asStateFlow()
// Data: pre-selected model in expansion mode // Data: pre-selected model in expansion mode
private val _preselectedModel = MutableStateFlow<ModelInfo?>(null) private val _preselectedModel = MutableStateFlow<ModelInfo?>(null)
val preselectedModel: StateFlow<ModelInfo?> = _preselectedModel.asStateFlow() private val _showRamWarning = MutableStateFlow(false)
val preselection = combine(
_preselectedModel,
performanceMonitor.monitorMemoryUsage(),
_showRamWarning,
) { model, memory, show ->
if (model == null) {
null
} else {
if (memory.availableMem >= model.sizeInBytes + RAM_LOAD_MODEL_BUFFER_BYTES) {
Preselection(model, null)
} else {
Preselection(model, RamWarning(model.sizeInBytes, memory.availableMem, show))
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = null
)
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -132,24 +156,58 @@ class ModelSelectionViewModel @Inject constructor(
} }
/** /**
* Pre-select a model * Pre-select a model to expand its details and show Run FAB
*/ */
fun preselectModel(modelInfo: ModelInfo, preselected: Boolean) = fun preselectModel(modelInfo: ModelInfo, preselected: Boolean) {
_preselectedModel.update { current -> _preselectedModel.value = if (preselected) modelInfo else null
if (preselected) modelInfo else null _showRamWarning.value = false
} }
/** /**
* Confirm currently selected model * Select the currently pre-selected model
*
* @return True if RAM enough, otherwise False.
*/ */
fun confirmSelectedModel(modelInfo: ModelInfo) = fun selectModel(preselection: Preselection) =
when (preselection.ramWarning?.showing) {
null -> {
inferenceService.setCurrentModel(preselection.modelInfo)
true
}
false -> {
_showRamWarning.value = true
false
}
else -> false
}
/**
* Acknowledge RAM warnings and confirm currently pre-selected model
*
* @return True if confirmed, otherwise False.
*/
fun confirmSelectedModel(modelInfo: ModelInfo, ramWarning: RamWarning): Boolean =
if (ramWarning.showing) {
inferenceService.setCurrentModel(modelInfo) inferenceService.setCurrentModel(modelInfo)
_showRamWarning.value = false
true
} else {
false
}
/**
* Dismiss the RAM warnings
*/
fun dismissRamWarning() {
_showRamWarning.value = false
}
/** /**
* Reset selected model to none (before navigating away) * Reset selected model to none (before navigating away)
*/ */
fun resetSelection() { fun resetSelection() {
_preselectedModel.value = null _preselectedModel.value = null
_showRamWarning.value = false
} }
/** /**
@ -164,6 +222,20 @@ class ModelSelectionViewModel @Inject constructor(
companion object { companion object {
private val TAG = ModelSelectionViewModel::class.java.simpleName private val TAG = ModelSelectionViewModel::class.java.simpleName
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L
private const val RAM_LOAD_MODEL_BUFFER_BYTES = 300 * 1024
} }
} }
data class Preselection(
val modelInfo: ModelInfo,
val ramWarning: RamWarning?,
) {
data class RamWarning(
val requiredRam: Long,
val availableRam: Long,
val showing: Boolean,
)
}