feature: support searching on Model Selection screen
This commit is contained in:
parent
2b3ba770dd
commit
77edad5a01
|
|
@ -118,16 +118,60 @@ fun AppContent(
|
|||
// Create scaffold's top & bottom bar configs based on current route
|
||||
val scaffoldConfig = when {
|
||||
// Model selection screen
|
||||
currentRoute == AppDestinations.MODEL_SELECTION_ROUTE ->
|
||||
currentRoute == AppDestinations.MODEL_SELECTION_ROUTE -> {
|
||||
// Collect states for bottom bar
|
||||
val isSearchActive by modelSelectionViewModel.isSearchActive.collectAsState()
|
||||
val sortOrder by modelSelectionViewModel.sortOrder.collectAsState()
|
||||
val showSortMenu by modelSelectionViewModel.showSortMenu.collectAsState()
|
||||
val activeFilters by modelSelectionViewModel.activeFilters.collectAsState()
|
||||
val showFilterMenu by modelSelectionViewModel.showFilterMenu.collectAsState()
|
||||
val preselectedModel by modelSelectionViewModel.preselectedModel.collectAsState()
|
||||
|
||||
ScaffoldConfig(
|
||||
topBarConfig = TopBarConfig.Default(
|
||||
title = "Models",
|
||||
topBarConfig =
|
||||
if (isSearchActive) TopBarConfig.None()
|
||||
else TopBarConfig.Default(
|
||||
title = "Select a Model",
|
||||
navigationIcon = NavigationIcon.Menu {
|
||||
modelSelectionViewModel.resetSelection()
|
||||
openDrawer()
|
||||
}
|
||||
),
|
||||
bottomBarConfig = BottomBarConfig.ModelSelection(
|
||||
search = BottomBarConfig.ModelSelection.SearchConfig(
|
||||
isActive = isSearchActive,
|
||||
onToggleSearch = modelSelectionViewModel::toggleSearchState,
|
||||
textFieldState = modelSelectionViewModel.searchFieldState,
|
||||
onSearch = { /* No-op for now */ }
|
||||
),
|
||||
sorting = BottomBarConfig.ModelSelection.SortingConfig(
|
||||
currentOrder = sortOrder,
|
||||
isMenuVisible = showSortMenu,
|
||||
toggleMenu = modelSelectionViewModel::toggleSortMenu,
|
||||
selectOrder = {
|
||||
modelSelectionViewModel.setSortOrder(it)
|
||||
modelSelectionViewModel.toggleSortMenu(false)
|
||||
}
|
||||
),
|
||||
filtering = BottomBarConfig.ModelSelection.FilteringConfig(
|
||||
isActive = activeFilters.any { it.value },
|
||||
filters = activeFilters,
|
||||
onToggleFilter = modelSelectionViewModel::toggleFilter,
|
||||
onClearFilters = modelSelectionViewModel::clearFilters,
|
||||
isMenuVisible = showFilterMenu,
|
||||
toggleMenu = modelSelectionViewModel::toggleFilterMenu
|
||||
),
|
||||
runAction = BottomBarConfig.ModelSelection.RunActionConfig(
|
||||
selectedModel = preselectedModel,
|
||||
onRun = { model ->
|
||||
modelSelectionViewModel.confirmSelectedModel(model)
|
||||
navigationActions.navigateToModelLoading()
|
||||
modelSelectionViewModel.toggleSearchState(false)
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Model loading screen
|
||||
currentRoute == AppDestinations.MODEL_LOADING_ROUTE ->
|
||||
|
|
@ -297,9 +341,6 @@ fun AppContent(
|
|||
// Model Selection Screen
|
||||
composable(AppDestinations.MODEL_SELECTION_ROUTE) {
|
||||
ModelSelectionScreen(
|
||||
onModelConfirmed = { modelInfo ->
|
||||
navigationActions.navigateToModelLoading()
|
||||
},
|
||||
onManageModelsClicked = {
|
||||
navigationActions.navigateToModelsManagement()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ fun AppScaffold(
|
|||
) {
|
||||
val topBar: @Composable () -> Unit = {
|
||||
when (val topConfig = topBarconfig) {
|
||||
is TopBarConfig.None -> {}
|
||||
|
||||
is TopBarConfig.Default -> DefaultTopBar(
|
||||
title = topBarconfig.title,
|
||||
onNavigateBack = topConfig.navigationIcon.backAction,
|
||||
|
|
@ -66,6 +68,15 @@ fun AppScaffold(
|
|||
when (val config = bottomBarConfig) {
|
||||
is BottomBarConfig.None -> { /* No bottom bar */ }
|
||||
|
||||
is BottomBarConfig.ModelSelection -> {
|
||||
ModelSelectionBottomBar(
|
||||
search = config.search,
|
||||
sorting = config.sorting,
|
||||
filtering = config.filtering,
|
||||
runAction = config.runAction
|
||||
)
|
||||
}
|
||||
|
||||
is BottomBarConfig.ModelsManagement -> {
|
||||
ModelsManagementBottomBar(
|
||||
sorting = config.sorting,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
package com.example.llama.revamp.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.Sort
|
||||
import androidx.compose.material.icons.automirrored.outlined.Backspace
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ClearAll
|
||||
|
|
@ -11,11 +15,17 @@ import androidx.compose.material.icons.filled.Delete
|
|||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.FilterAlt
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
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.outlined.FilterAlt
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -37,6 +47,41 @@ sealed class BottomBarConfig {
|
|||
|
||||
object None : BottomBarConfig()
|
||||
|
||||
data class ModelSelection(
|
||||
val search: SearchConfig,
|
||||
val sorting: SortingConfig,
|
||||
val filtering: FilteringConfig,
|
||||
val runAction: RunActionConfig
|
||||
) : BottomBarConfig() {
|
||||
data class SearchConfig(
|
||||
val isActive: Boolean,
|
||||
val onToggleSearch: (Boolean) -> Unit,
|
||||
val textFieldState: TextFieldState,
|
||||
val onSearch: (String) -> Unit,
|
||||
)
|
||||
|
||||
data class SortingConfig(
|
||||
val currentOrder: ModelSortOrder,
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit,
|
||||
val selectOrder: (ModelSortOrder) -> Unit
|
||||
)
|
||||
|
||||
data class FilteringConfig(
|
||||
val isActive: Boolean,
|
||||
val filters: Map<String, Boolean>, // Filter name -> enabled
|
||||
val onToggleFilter: (String, Boolean) -> Unit,
|
||||
val onClearFilters: () -> Unit,
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit
|
||||
)
|
||||
|
||||
data class RunActionConfig(
|
||||
val selectedModel: ModelInfo?,
|
||||
val onRun: (ModelInfo) -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
data class ModelsManagement(
|
||||
val sorting: SortingConfig,
|
||||
val filtering: FilteringConfig,
|
||||
|
|
@ -70,7 +115,140 @@ sealed class BottomBarConfig {
|
|||
)
|
||||
}
|
||||
|
||||
// TODO-han.yin: add more bottom bar types here
|
||||
// TODO-han.yin: add bottom bar config for Conversation Screen!
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModelSelectionBottomBar(
|
||||
search: BottomBarConfig.ModelSelection.SearchConfig,
|
||||
sorting: BottomBarConfig.ModelSelection.SortingConfig,
|
||||
filtering: BottomBarConfig.ModelSelection.FilteringConfig,
|
||||
runAction: BottomBarConfig.ModelSelection.RunActionConfig
|
||||
) {
|
||||
BottomAppBar(
|
||||
actions = {
|
||||
if (search.isActive) {
|
||||
// Quit search action
|
||||
IconButton(onClick = { search.onToggleSearch(false) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SearchOff,
|
||||
contentDescription = "Quit search mode"
|
||||
)
|
||||
}
|
||||
|
||||
// Clear query action
|
||||
IconButton(onClick = { search.textFieldState.clearText() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.Backspace,
|
||||
contentDescription = "Clear query text"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Enter search action
|
||||
IconButton(onClick = { search.onToggleSearch(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = "Search models"
|
||||
)
|
||||
}
|
||||
|
||||
// Sorting action
|
||||
IconButton(onClick = { sorting.toggleMenu(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Sort,
|
||||
contentDescription = "Sort models"
|
||||
)
|
||||
}
|
||||
|
||||
// Sorting dropdown menu
|
||||
DropdownMenu(
|
||||
expanded = sorting.isMenuVisible,
|
||||
onDismissRequest = { sorting.toggleMenu(false) }
|
||||
) {
|
||||
val sortOptions = listOf(
|
||||
Triple(ModelSortOrder.NAME_ASC, "Name (A-Z)", "Sort by name in ascending order"),
|
||||
Triple(ModelSortOrder.NAME_DESC, "Name (Z-A)", "Sort by name in descending order"),
|
||||
Triple(ModelSortOrder.SIZE_ASC, "Size (Smallest first)", "Sort by size in ascending order"),
|
||||
Triple(ModelSortOrder.SIZE_DESC, "Size (Largest first)", "Sort by size in descending order"),
|
||||
Triple(ModelSortOrder.LAST_USED, "Last used", "Sort by last used")
|
||||
)
|
||||
|
||||
sortOptions.forEach { (order, label, description) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
trailingIcon = {
|
||||
if (sorting.currentOrder == order)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "$description, selected"
|
||||
)
|
||||
},
|
||||
onClick = { sorting.selectOrder(order) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter action
|
||||
IconButton(onClick = { filtering.toggleMenu(true) }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (filtering.isActive) Icons.Default.FilterAlt
|
||||
else Icons.Outlined.FilterAlt,
|
||||
contentDescription = "Filter models"
|
||||
)
|
||||
}
|
||||
|
||||
// Filter dropdown menu
|
||||
DropdownMenu(
|
||||
expanded = filtering.isMenuVisible,
|
||||
onDismissRequest = { filtering.toggleMenu(false) }
|
||||
) {
|
||||
Text(
|
||||
text = "Filter by",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
filtering.filters.forEach { (filter, isEnabled) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(filter) },
|
||||
leadingIcon = {
|
||||
Checkbox(
|
||||
checked = isEnabled,
|
||||
onCheckedChange = null
|
||||
)
|
||||
},
|
||||
onClick = { filtering.onToggleFilter(filter, !isEnabled) }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text("Clear filters") },
|
||||
onClick = {
|
||||
filtering.onClearFilters()
|
||||
filtering.toggleMenu(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
// Only show FAB if a model is selected
|
||||
runAction.selectedModel?.let { model ->
|
||||
FloatingActionButton(
|
||||
onClick = { runAction.onRun(model) },
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = "Run with selected model"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.WarningAmber
|
|||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Label
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
|
|
@ -36,6 +35,12 @@ sealed class TopBarConfig {
|
|||
abstract val title: String
|
||||
abstract val navigationIcon: NavigationIcon
|
||||
|
||||
// No top bar at all
|
||||
data class None(
|
||||
override val title: String = "",
|
||||
override val navigationIcon: NavigationIcon = NavigationIcon.None
|
||||
) : TopBarConfig()
|
||||
|
||||
// Default/simple top bar with only a navigation icon
|
||||
data class Default(
|
||||
override val title: String,
|
||||
|
|
|
|||
|
|
@ -1,71 +1,174 @@
|
|||
package com.example.llama.revamp.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SearchOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DockedSearchBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.llama.revamp.data.model.ModelInfo
|
||||
import com.example.llama.revamp.ui.components.ModelCardFullExpandable
|
||||
import com.example.llama.revamp.viewmodel.ModelSelectionViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ModelSelectionScreen(
|
||||
onModelConfirmed: (ModelInfo) -> Unit,
|
||||
onManageModelsClicked: () -> Unit,
|
||||
viewModel: ModelSelectionViewModel,
|
||||
) {
|
||||
val models by viewModel.availableModels.collectAsState()
|
||||
val filteredModels by viewModel.filteredModels.collectAsState()
|
||||
val preselectedModel by viewModel.preselectedModel.collectAsState()
|
||||
|
||||
// Handle back button press
|
||||
BackHandler(preselectedModel != null) {
|
||||
viewModel.onBackPressed()
|
||||
val textFieldState = viewModel.searchFieldState
|
||||
val isSearchActive by viewModel.isSearchActive.collectAsState()
|
||||
val searchQuery by remember(textFieldState) {
|
||||
derivedStateOf { textFieldState.text.toString() }
|
||||
}
|
||||
val queryResults by viewModel.queryResults.collectAsState()
|
||||
|
||||
val activeFilters by viewModel.activeFilters.collectAsState()
|
||||
val activeFiltersCount by remember(activeFilters) {
|
||||
derivedStateOf { activeFilters.count { it.value } }
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val toggleSearchFocusAndIme: (Boolean) -> Unit = { show ->
|
||||
if (show) {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
} else {
|
||||
focusRequester.freeFocus()
|
||||
keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle back button press
|
||||
BackHandler(preselectedModel != null || isSearchActive) {
|
||||
if (isSearchActive) {
|
||||
viewModel.toggleSearchState(false)
|
||||
} else {
|
||||
viewModel.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect (isSearchActive) {
|
||||
if (isSearchActive) {
|
||||
toggleSearchFocusAndIme(true)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (models.isEmpty()) {
|
||||
EmptyModelsView(onManageModelsClicked)
|
||||
if (isSearchActive) {
|
||||
DockedSearchBar(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
query = textFieldState.text.toString(),
|
||||
onQueryChange = { textFieldState.edit { replace(0, length, it) } },
|
||||
onSearch = {},
|
||||
expanded = true,
|
||||
onExpandedChange = { expanded ->
|
||||
viewModel.toggleSearchState(expanded)
|
||||
textFieldState.clearText()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = { Icon(Icons.Default.MoreVert, contentDescription = null) },
|
||||
placeholder = { Text("Type to search your models") }
|
||||
)
|
||||
},
|
||||
expanded = true,
|
||||
onExpandedChange = {
|
||||
viewModel.toggleSearchState(it)
|
||||
}
|
||||
) {
|
||||
if (queryResults.isEmpty()) {
|
||||
if (searchQuery.isNotBlank()) {
|
||||
// Show "no results" message
|
||||
EmptySearchResultsView(
|
||||
onClearSearch = {
|
||||
textFieldState.clearText()
|
||||
toggleSearchFocusAndIme(true)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
|
||||
) {
|
||||
items(items = models, key = { it.id }) { model ->
|
||||
items(items = queryResults, key = { it.id }) { model ->
|
||||
ModelCardFullExpandable(
|
||||
model = model,
|
||||
isSelected = if (model == preselectedModel) true else null,
|
||||
onSelected = { selected ->
|
||||
if (selected) {
|
||||
toggleSearchFocusAndIme(false)
|
||||
} else {
|
||||
viewModel.resetSelection()
|
||||
toggleSearchFocusAndIme(true)
|
||||
}
|
||||
},
|
||||
isExpanded = model == preselectedModel,
|
||||
onExpanded = { expanded ->
|
||||
viewModel.preselectModel(model, expanded)
|
||||
toggleSearchFocusAndIme(!expanded)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (filteredModels.isEmpty()) {
|
||||
EmptyModelsView(activeFiltersCount, onManageModelsClicked)
|
||||
} else {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(), // .padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
|
||||
) {
|
||||
items(items = filteredModels, key = { it.id }) { model ->
|
||||
ModelCardFullExpandable(
|
||||
model = model,
|
||||
isSelected = if (model == preselectedModel) true else null,
|
||||
|
|
@ -81,38 +184,56 @@ fun ModelSelectionScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show FAB if a model is selected
|
||||
AnimatedVisibility(
|
||||
modifier = Modifier.padding(16.dp).align(Alignment.BottomEnd),
|
||||
visible = preselectedModel != null,
|
||||
enter = scaleIn() + fadeIn(),
|
||||
exit = scaleOut() + fadeOut()
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
preselectedModel?.let {
|
||||
viewModel.confirmSelectedModel(it)
|
||||
onModelConfirmed(it)
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptySearchResultsView(
|
||||
onClearSearch: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = "Start with selected model"
|
||||
imageVector = Icons.Default.SearchOff,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "No matching models found",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Try a different search term",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(onClick = onClearSearch) {
|
||||
Text("Clear Search")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyModelsView(onManageModelsClicked: () -> Unit) {
|
||||
private fun EmptyModelsView(
|
||||
activeFiltersCount: Int,
|
||||
onManageModelsClicked: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
|
@ -133,8 +254,12 @@ private fun EmptyModelsView(onManageModelsClicked: () -> Unit) {
|
|||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Add models to get started with local LLM inference",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = when (activeFiltersCount) {
|
||||
0 -> "Import some models to get started!"
|
||||
1 -> "No models match the selected filter"
|
||||
else -> "No models match the selected filters"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,38 +1,179 @@
|
|||
package com.example.llama.revamp.viewmodel
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.llama.revamp.data.model.ModelInfo
|
||||
import com.example.llama.revamp.data.repository.ModelRepository
|
||||
import com.example.llama.revamp.engine.InferenceService
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@HiltViewModel
|
||||
class ModelSelectionViewModel @Inject constructor(
|
||||
private val inferenceService: InferenceService,
|
||||
modelRepository: ModelRepository
|
||||
) : ViewModel() {
|
||||
|
||||
// UI state: search mode
|
||||
private val _isSearchActive = MutableStateFlow(false)
|
||||
val isSearchActive: StateFlow<Boolean> = _isSearchActive.asStateFlow()
|
||||
|
||||
fun toggleSearchState(active: Boolean) {
|
||||
_isSearchActive.value = active
|
||||
if (active) {
|
||||
resetSelection()
|
||||
} else {
|
||||
searchFieldState.clearText()
|
||||
}
|
||||
}
|
||||
|
||||
val searchFieldState = TextFieldState()
|
||||
|
||||
// UI state: sort menu
|
||||
private val _sortOrder = MutableStateFlow(ModelSortOrder.LAST_USED)
|
||||
val sortOrder: StateFlow<ModelSortOrder> = _sortOrder.asStateFlow()
|
||||
|
||||
fun setSortOrder(order: ModelSortOrder) {
|
||||
_sortOrder.value = order
|
||||
}
|
||||
|
||||
private val _showSortMenu = MutableStateFlow(false)
|
||||
val showSortMenu: StateFlow<Boolean> = _showSortMenu.asStateFlow()
|
||||
|
||||
fun toggleSortMenu(visible: Boolean) {
|
||||
_showSortMenu.value = visible
|
||||
}
|
||||
|
||||
// UI state: filters
|
||||
// TODO-han.yin: Refactor this into Enums!
|
||||
private val _activeFilters = MutableStateFlow<Map<String, Boolean>>(mapOf(
|
||||
"Has context length" to false,
|
||||
"Support system prompt" to false,
|
||||
"7B models" to false,
|
||||
"13B models" to false,
|
||||
"70B models" to false
|
||||
))
|
||||
val activeFilters: StateFlow<Map<String, Boolean>> = _activeFilters.asStateFlow()
|
||||
|
||||
fun toggleFilter(filter: String, enabled: Boolean) {
|
||||
_activeFilters.update { current ->
|
||||
current.toMutableMap().apply {
|
||||
this[filter] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFilters() {
|
||||
_activeFilters.update { current ->
|
||||
current.mapValues { false }
|
||||
}
|
||||
}
|
||||
|
||||
private val _showFilterMenu = MutableStateFlow(false)
|
||||
val showFilterMenu: StateFlow<Boolean> = _showFilterMenu.asStateFlow()
|
||||
|
||||
fun toggleFilterMenu(visible: Boolean) {
|
||||
_showFilterMenu.value = visible
|
||||
}
|
||||
|
||||
// Data: filtered & sorted models
|
||||
private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList())
|
||||
val filteredModels: StateFlow<List<ModelInfo>> = _filteredModels.asStateFlow()
|
||||
|
||||
// Data: queried models
|
||||
private val _queryResults = MutableStateFlow<List<ModelInfo>>(emptyList())
|
||||
val queryResults: StateFlow<List<ModelInfo>> = _queryResults.asStateFlow()
|
||||
|
||||
// Data: pre-selected model in expansion mode
|
||||
private val _preselectedModel = MutableStateFlow<ModelInfo?>(null)
|
||||
val preselectedModel: StateFlow<ModelInfo?> = _preselectedModel.asStateFlow()
|
||||
|
||||
/**
|
||||
* Available models for selection
|
||||
*/
|
||||
val availableModels: StateFlow<List<ModelInfo>> = modelRepository.getModels()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
|
||||
initialValue = emptyList()
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
modelRepository.getModels(),
|
||||
_activeFilters,
|
||||
_sortOrder,
|
||||
) { models, filters, sortOrder ->
|
||||
models.filterBy(filters).sortByOrder(sortOrder)
|
||||
}.collect {
|
||||
_filteredModels.value = it
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
modelRepository.getModels(),
|
||||
snapshotFlow { searchFieldState.text }.debounce(QUERY_DEBOUNCE_TIMEOUT_MS)
|
||||
) { models, query ->
|
||||
if (query.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
models.queryBy(query.toString()).sortedBy { it.dateLastUsed ?: it.dateAdded }
|
||||
}
|
||||
}.collectLatest {
|
||||
_queryResults.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ModelInfo>.queryBy(query: String): List<ModelInfo> {
|
||||
if (query.isBlank()) return this
|
||||
|
||||
return filter { model ->
|
||||
model.name.contains(query, ignoreCase = true) ||
|
||||
model.metadata.fullModelName?.contains(query, ignoreCase = true) == true ||
|
||||
model.metadata.additional?.tags?.any { it.contains(query, ignoreCase = true) } == true ||
|
||||
model.metadata.additional?.languages?.any { it.contains(query, ignoreCase = true) } == true ||
|
||||
model.metadata.architecture?.architecture?.contains(query, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO-han.yin: Refactor this into Enums!
|
||||
private fun List<ModelInfo>.filterBy(filters: Map<String, Boolean>): List<ModelInfo> {
|
||||
val activeFilters = filters.filterValues { it }
|
||||
if (activeFilters.isEmpty()) return this
|
||||
|
||||
return filter { model ->
|
||||
activeFilters.all { (filter, _) ->
|
||||
when (filter) {
|
||||
"Has context length" -> model.metadata.dimensions?.contextLength != null
|
||||
"Support system prompt" -> true
|
||||
"7B models" -> model.metadata.basic.sizeLabel?.contains("7B") == true
|
||||
"13B models" -> model.metadata.basic.sizeLabel?.contains("13B") == true
|
||||
"70B models" -> model.metadata.basic.sizeLabel?.contains("70B") == true
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ModelInfo>.sortByOrder(order: ModelSortOrder): List<ModelInfo> {
|
||||
return when (order) {
|
||||
ModelSortOrder.NAME_ASC -> sortedBy { it.name }
|
||||
ModelSortOrder.NAME_DESC -> sortedByDescending { it.name }
|
||||
ModelSortOrder.SIZE_ASC -> sortedBy { it.sizeInBytes }
|
||||
ModelSortOrder.SIZE_DESC -> sortedByDescending { it.sizeInBytes }
|
||||
ModelSortOrder.LAST_USED -> sortedWith(
|
||||
compareByDescending<ModelInfo> { it.dateLastUsed }
|
||||
.thenBy { it.name }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-select a model
|
||||
|
|
@ -67,6 +208,6 @@ class ModelSelectionViewModel @Inject constructor(
|
|||
companion object {
|
||||
private val TAG = ModelSelectionViewModel::class.java.simpleName
|
||||
|
||||
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
|
||||
private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue