UI: split the ModelsManagementViewModel from a unified ModelsViewModel due to huge complexity
This commit is contained in:
parent
df16abe75e
commit
a4881cb87b
|
|
@ -53,9 +53,10 @@ import com.example.llama.viewmodel.BenchmarkViewModel
|
|||
import com.example.llama.viewmodel.ConversationViewModel
|
||||
import com.example.llama.viewmodel.MainViewModel
|
||||
import com.example.llama.viewmodel.ModelLoadingViewModel
|
||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
||||
import com.example.llama.viewmodel.ModelsManagementViewModel
|
||||
import com.example.llama.viewmodel.ModelsViewModel
|
||||
import com.example.llama.viewmodel.SettingsViewModel
|
||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -85,10 +86,10 @@ fun AppContent(
|
|||
settingsViewModel: SettingsViewModel,
|
||||
mainViewModel: MainViewModel = hiltViewModel(),
|
||||
modelsViewModel: ModelsViewModel = hiltViewModel(),
|
||||
modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
|
||||
modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(),
|
||||
benchmarkViewModel: BenchmarkViewModel = hiltViewModel(),
|
||||
conversationViewModel: ConversationViewModel = hiltViewModel(),
|
||||
// modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
|
@ -191,19 +192,20 @@ fun AppContent(
|
|||
// Model selection screen
|
||||
currentRoute == AppDestinations.MODELS_ROUTE -> {
|
||||
// Collect states for bottom bar
|
||||
val filteredModels by modelsViewModel.filteredModels.collectAsState()
|
||||
val sortOrder by modelsViewModel.sortOrder.collectAsState()
|
||||
val showSortMenu by modelsViewModel.showSortMenu.collectAsState()
|
||||
val activeFilters by modelsViewModel.activeFilters.collectAsState()
|
||||
val showFilterMenu by modelsViewModel.showFilterMenu.collectAsState()
|
||||
val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
|
||||
|
||||
val selectedModelsToDelete by modelsViewModel.selectedModelsToDelete.collectAsState()
|
||||
val showImportModelMenu by modelsViewModel.showImportModelMenu.collectAsState()
|
||||
val selectedModelsToDelete by modelsManagementViewModel.selectedModelsToDelete.collectAsState()
|
||||
val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState()
|
||||
|
||||
// Create file launcher for importing local models
|
||||
val fileLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri -> uri?.let { modelsViewModel.importLocalModelFileSelected(it) } }
|
||||
) { uri -> uri?.let { modelsManagementViewModel.importLocalModelFileSelected(it) } }
|
||||
|
||||
ScaffoldConfig(
|
||||
topBarConfig =
|
||||
|
|
@ -231,6 +233,7 @@ fun AppContent(
|
|||
TopBarConfig.ModelsDeleting(
|
||||
title = "Deleting models",
|
||||
navigationIcon = NavigationIcon.Quit {
|
||||
modelsManagementViewModel.resetManagementState()
|
||||
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||
},
|
||||
)
|
||||
|
|
@ -309,14 +312,14 @@ fun AppContent(
|
|||
),
|
||||
importing = BottomBarConfig.Models.Management.ImportConfig(
|
||||
isMenuVisible = showImportModelMenu,
|
||||
toggleMenu = { show -> modelsViewModel.toggleImportMenu(show) },
|
||||
toggleMenu = { show -> modelsManagementViewModel.toggleImportMenu(show) },
|
||||
importFromLocal = {
|
||||
fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
|
||||
modelsViewModel.toggleImportMenu(false)
|
||||
modelsManagementViewModel.toggleImportMenu(false)
|
||||
},
|
||||
importFromHuggingFace = {
|
||||
modelsViewModel.queryModelsFromHuggingFace()
|
||||
modelsViewModel.toggleImportMenu(false)
|
||||
modelsManagementViewModel.queryModelsFromHuggingFace()
|
||||
modelsManagementViewModel.toggleImportMenu(false)
|
||||
}
|
||||
),
|
||||
onToggleDeleting = {
|
||||
|
|
@ -330,11 +333,16 @@ fun AppContent(
|
|||
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||
},
|
||||
selectedModels = selectedModelsToDelete,
|
||||
toggleAllSelection = { modelsViewModel.toggleAllSelectedModelsToDelete(it) },
|
||||
selectAllFilteredModels = {
|
||||
modelsManagementViewModel.selectAllFilteredModelsToDelete(filteredModels)
|
||||
},
|
||||
clearAllSelectedModels = {
|
||||
modelsManagementViewModel.clearAllSelectedModelsToDelete()
|
||||
},
|
||||
deleteSelected = {
|
||||
selectedModelsToDelete.let {
|
||||
if (it.isNotEmpty()) {
|
||||
modelsViewModel.batchDeletionClicked(it)
|
||||
modelsManagementViewModel.batchDeletionClicked(it)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -464,7 +472,8 @@ fun AppContent(
|
|||
}
|
||||
},
|
||||
onScaffoldEvent = handleScaffoldEvent,
|
||||
viewModel = modelsViewModel
|
||||
modelsViewModel = modelsViewModel,
|
||||
managementViewModel = modelsManagementViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.example.llama.ui.scaffold
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
|
|
@ -77,6 +78,8 @@ fun AppScaffold(
|
|||
|
||||
is TopBarConfig.ModelsDeleting -> DefaultTopBar(
|
||||
title = topBarconfig.title,
|
||||
titleColor = MaterialTheme.colorScheme.error,
|
||||
navigationIconTint = MaterialTheme.colorScheme.error,
|
||||
onQuit = topBarconfig.navigationIcon.quitAction
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,8 @@ sealed class BottomBarConfig {
|
|||
data class Deleting(
|
||||
val onQuitDeleting: () -> Unit,
|
||||
val selectedModels: Map<String, ModelInfo>,
|
||||
val toggleAllSelection: (Boolean) -> Unit,
|
||||
val selectAllFilteredModels: () -> Unit,
|
||||
val clearAllSelectedModels: () -> Unit,
|
||||
val deleteSelected: () -> Unit
|
||||
) : BottomBarConfig()
|
||||
|
||||
|
|
|
|||
|
|
@ -23,14 +23,14 @@ fun ModelsDeletingBottomBar(
|
|||
) {
|
||||
BottomAppBar(
|
||||
actions = {
|
||||
IconButton(onClick = { deleting.toggleAllSelection(false) }) {
|
||||
IconButton(onClick = { deleting.clearAllSelectedModels() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ClearAll,
|
||||
contentDescription = "Deselect all"
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { deleting.toggleAllSelection(true) }) {
|
||||
IconButton(onClick = { deleting.selectAllFilteredModels() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
contentDescription = "Select all"
|
||||
|
|
|
|||
|
|
@ -12,24 +12,30 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DefaultTopBar(
|
||||
title: String,
|
||||
titleColor: Color = Color.Unspecified,
|
||||
navigationIconTint: Color = Color.Unspecified,
|
||||
onNavigateBack: (() -> Unit)? = null,
|
||||
onQuit: (() -> Unit)? = null,
|
||||
onMenuOpen: (() -> Unit)? = null
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
title = {
|
||||
Text(text = title, color = titleColor)
|
||||
},
|
||||
navigationIcon = {
|
||||
when {
|
||||
onQuit != null -> {
|
||||
IconButton(onClick = onQuit) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Quit"
|
||||
contentDescription = "Quit",
|
||||
tint = navigationIconTint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +44,8 @@ fun DefaultTopBar(
|
|||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
contentDescription = "Back",
|
||||
tint = navigationIconTint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +54,8 @@ fun DefaultTopBar(
|
|||
IconButton(onClick = onMenuOpen) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = "Menu"
|
||||
contentDescription = "Menu",
|
||||
tint = navigationIconTint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,33 +9,23 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
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.InfoView
|
||||
import com.example.llama.ui.components.ModelCardFullExpandable
|
||||
import com.example.llama.viewmodel.ModelsViewModel
|
||||
import com.example.llama.viewmodel.PreselectedModelToRun
|
||||
|
||||
@Composable
|
||||
fun ModelsBrowsingScreen(
|
||||
filteredModels: List<ModelInfo>,
|
||||
activeFiltersCount: Int,
|
||||
preselection: PreselectedModelToRun?,
|
||||
onManageModelsClicked: () -> Unit,
|
||||
viewModel: ModelsViewModel,
|
||||
) {
|
||||
// Data: models
|
||||
val filteredModels by viewModel.filteredModels.collectAsState()
|
||||
val preselection by viewModel.preselectedModelToRun.collectAsState()
|
||||
|
||||
// Filter states
|
||||
val activeFilters by viewModel.activeFilters.collectAsState()
|
||||
val activeFiltersCount by remember(activeFilters) {
|
||||
derivedStateOf { activeFilters.count { it.value } }
|
||||
}
|
||||
|
||||
|
||||
if (filteredModels.isEmpty()) {
|
||||
// Empty model prompt
|
||||
EmptyModelsView(activeFiltersCount, onManageModelsClicked)
|
||||
|
|
@ -63,7 +53,6 @@ fun ModelsBrowsingScreen(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun EmptyModelsView(
|
||||
activeFiltersCount: Int,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ import androidx.compose.material3.TextButton
|
|||
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.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -55,6 +54,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.net.toUri
|
||||
import com.example.llama.data.model.ModelFilter
|
||||
import com.example.llama.data.model.ModelInfo
|
||||
import com.example.llama.data.source.remote.HuggingFaceModel
|
||||
import com.example.llama.ui.components.InfoAction
|
||||
|
|
@ -68,6 +68,7 @@ import com.example.llama.viewmodel.ModelManagementState.Deletion
|
|||
import com.example.llama.viewmodel.ModelManagementState.Download
|
||||
import com.example.llama.viewmodel.ModelManagementState.Importation
|
||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
||||
import com.example.llama.viewmodel.ModelsManagementViewModel
|
||||
import com.example.llama.viewmodel.ModelsViewModel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
|
@ -77,24 +78,18 @@ import java.util.Locale
|
|||
*/
|
||||
@Composable
|
||||
fun ModelsManagementAndDeletingScreen(
|
||||
filteredModels: List<ModelInfo>,
|
||||
activeFiltersCount: Int,
|
||||
isDeleting: Boolean,
|
||||
onScaffoldEvent: (ScaffoldEvent) -> Unit,
|
||||
viewModel: ModelsViewModel,
|
||||
modelsViewModel: ModelsViewModel,
|
||||
managementViewModel: ModelsManagementViewModel,
|
||||
) {
|
||||
// Data: models
|
||||
val filteredModels by viewModel.filteredModels.collectAsState()
|
||||
|
||||
// Selection state
|
||||
val selectedModels by viewModel.selectedModelsToDelete.collectAsState()
|
||||
|
||||
// Filter state
|
||||
val activeFilters by viewModel.activeFilters.collectAsState()
|
||||
val activeFiltersCount by remember(activeFilters) {
|
||||
derivedStateOf { activeFilters.count { it.value } }
|
||||
}
|
||||
val selectedModels by managementViewModel.selectedModelsToDelete.collectAsState()
|
||||
|
||||
// Model management state
|
||||
val managementState by viewModel.managementState.collectAsState()
|
||||
val managementState by managementViewModel.managementState.collectAsState()
|
||||
|
||||
// UI states
|
||||
val expandedModels = remember { mutableStateMapOf<String, ModelInfo>() }
|
||||
|
|
@ -136,7 +131,7 @@ fun ModelsManagementAndDeletingScreen(
|
|||
isSelected = isSelected,
|
||||
onSelected = {
|
||||
if (isDeleting) {
|
||||
viewModel.toggleModelSelectionById(model.id)
|
||||
managementViewModel.toggleModelSelectionById(filteredModels, model.id)
|
||||
}
|
||||
},
|
||||
isExpanded = expandedModels.contains(model.id),
|
||||
|
|
@ -161,11 +156,11 @@ fun ModelsManagementAndDeletingScreen(
|
|||
isImporting = false,
|
||||
progress = 0.0f,
|
||||
onConfirm = {
|
||||
viewModel.importLocalModelFileConfirmed(
|
||||
managementViewModel.importLocalModelFileConfirmed(
|
||||
state.uri, state.fileName, state.fileSize
|
||||
)
|
||||
},
|
||||
onCancel = { viewModel.resetManagementState() }
|
||||
onCancel = { managementViewModel.resetManagementState() }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +172,7 @@ fun ModelsManagementAndDeletingScreen(
|
|||
isCancelling = state.isCancelling,
|
||||
progress = state.progress,
|
||||
onConfirm = {},
|
||||
onCancel = { viewModel.cancelOngoingLocalModelImport() },
|
||||
onCancel = { managementViewModel.cancelOngoingLocalModelImport() },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +181,7 @@ fun ModelsManagementAndDeletingScreen(
|
|||
title = "Import Failed",
|
||||
message = state.message,
|
||||
learnMoreUrl = state.learnMoreUrl,
|
||||
onDismiss = { viewModel.resetManagementState() }
|
||||
onDismiss = { managementViewModel.resetManagementState() }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -198,21 +193,21 @@ fun ModelsManagementAndDeletingScreen(
|
|||
)
|
||||
)
|
||||
|
||||
viewModel.resetManagementState()
|
||||
managementViewModel.resetManagementState()
|
||||
}
|
||||
}
|
||||
|
||||
is Download.Querying -> {
|
||||
ImportFromHuggingFaceDialog(
|
||||
onCancel = { viewModel.resetManagementState() }
|
||||
onCancel = { managementViewModel.resetManagementState() }
|
||||
)
|
||||
}
|
||||
|
||||
is Download.Ready -> {
|
||||
ImportFromHuggingFaceDialog(
|
||||
models = state.models,
|
||||
onConfirm = { viewModel.downloadHuggingFaceModelConfirmed(it) },
|
||||
onCancel = { viewModel.resetManagementState() }
|
||||
onConfirm = { managementViewModel.downloadHuggingFaceModelConfirmed(it) },
|
||||
onCancel = { managementViewModel.resetManagementState() }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -225,7 +220,7 @@ fun ModelsManagementAndDeletingScreen(
|
|||
)
|
||||
)
|
||||
|
||||
viewModel.resetManagementState()
|
||||
managementViewModel.resetManagementState()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -236,11 +231,11 @@ fun ModelsManagementAndDeletingScreen(
|
|||
isImporting = false,
|
||||
progress = 0.0f,
|
||||
onConfirm = {
|
||||
viewModel.importLocalModelFileConfirmed(
|
||||
managementViewModel.importLocalModelFileConfirmed(
|
||||
state.uri, state.fileName, state.fileSize
|
||||
)
|
||||
},
|
||||
onCancel = { viewModel.resetManagementState() }
|
||||
onCancel = { managementViewModel.resetManagementState() }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -248,15 +243,15 @@ fun ModelsManagementAndDeletingScreen(
|
|||
ErrorDialog(
|
||||
title = "Download Failed",
|
||||
message = state.message,
|
||||
onDismiss = { viewModel.resetManagementState() }
|
||||
onDismiss = { managementViewModel.resetManagementState() }
|
||||
)
|
||||
}
|
||||
|
||||
is Deletion.Confirming -> {
|
||||
BatchDeleteConfirmationDialog(
|
||||
count = state.models.size,
|
||||
onConfirm = { viewModel.deleteModels(state.models) },
|
||||
onDismiss = { viewModel.resetManagementState() },
|
||||
onConfirm = { managementViewModel.deleteModels(state.models) },
|
||||
onDismiss = { managementViewModel.resetManagementState() },
|
||||
isDeleting = false
|
||||
)
|
||||
}
|
||||
|
|
@ -274,19 +269,20 @@ fun ModelsManagementAndDeletingScreen(
|
|||
ErrorDialog(
|
||||
title = "Deletion Failed",
|
||||
message = state.message,
|
||||
onDismiss = { viewModel.resetManagementState() }
|
||||
onDismiss = { managementViewModel.resetManagementState() }
|
||||
)
|
||||
}
|
||||
|
||||
is Deletion.Success -> {
|
||||
LaunchedEffect(state) {
|
||||
viewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||
|
||||
val count = state.models.size
|
||||
onScaffoldEvent(
|
||||
ScaffoldEvent.ShowSnackbar(
|
||||
message = "Deleted $count ${if (count > 1) "models" else "model"}.",
|
||||
duration = SnackbarDuration.Long
|
||||
withDismissAction = true,
|
||||
duration = SnackbarDuration.Long,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,16 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.example.llama.data.model.ModelInfo
|
||||
import com.example.llama.ui.components.InfoView
|
||||
import com.example.llama.ui.scaffold.ScaffoldEvent
|
||||
import com.example.llama.util.formatFileByteSize
|
||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
||||
import com.example.llama.viewmodel.ModelsManagementViewModel
|
||||
import com.example.llama.viewmodel.ModelsViewModel
|
||||
import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
|
||||
|
||||
|
|
@ -29,31 +32,39 @@ fun ModelsScreen(
|
|||
onManageModelsClicked: () -> Unit,
|
||||
onConfirmSelection: (ModelInfo, RamWarning) -> Unit,
|
||||
onScaffoldEvent: (ScaffoldEvent) -> Unit,
|
||||
viewModel: ModelsViewModel,
|
||||
modelsViewModel: ModelsViewModel,
|
||||
managementViewModel: ModelsManagementViewModel,
|
||||
) {
|
||||
// Data
|
||||
val preselection by viewModel.preselectedModelToRun.collectAsState()
|
||||
val filteredModels by modelsViewModel.filteredModels.collectAsState()
|
||||
val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
|
||||
|
||||
// UI states: Filter
|
||||
val activeFilters by modelsViewModel.activeFilters.collectAsState()
|
||||
val activeFiltersCount by remember(activeFilters) {
|
||||
derivedStateOf { activeFilters.count { it.value } }
|
||||
}
|
||||
|
||||
// UI states
|
||||
val currentMode by viewModel.modelScreenUiMode.collectAsState()
|
||||
val currentMode by modelsViewModel.modelScreenUiMode.collectAsState()
|
||||
|
||||
// Handle back button press
|
||||
BackHandler {
|
||||
when (currentMode) {
|
||||
ModelScreenUiMode.BROWSING -> {
|
||||
if (preselection != null) {
|
||||
viewModel.resetPreselection()
|
||||
modelsViewModel.resetPreselection()
|
||||
}
|
||||
}
|
||||
ModelScreenUiMode.SEARCHING -> {
|
||||
viewModel.toggleMode(ModelScreenUiMode.BROWSING)
|
||||
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
|
||||
}
|
||||
ModelScreenUiMode.MANAGING -> {
|
||||
viewModel.toggleMode(ModelScreenUiMode.BROWSING)
|
||||
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
|
||||
}
|
||||
ModelScreenUiMode.DELETING -> {
|
||||
viewModel.toggleAllSelectedModelsToDelete(false)
|
||||
viewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||
managementViewModel.clearAllSelectedModelsToDelete()
|
||||
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,16 +75,25 @@ fun ModelsScreen(
|
|||
when (currentMode) {
|
||||
ModelScreenUiMode.BROWSING ->
|
||||
ModelsBrowsingScreen(
|
||||
filteredModels = filteredModels,
|
||||
preselection = preselection,
|
||||
onManageModelsClicked = { /* TODO-han.yin */ },
|
||||
viewModel = viewModel
|
||||
activeFiltersCount = activeFiltersCount,
|
||||
viewModel = modelsViewModel,
|
||||
)
|
||||
ModelScreenUiMode.SEARCHING ->
|
||||
ModelsSearchingScreen(viewModel = viewModel)
|
||||
ModelsSearchingScreen(
|
||||
preselection = preselection,
|
||||
viewModel = modelsViewModel
|
||||
)
|
||||
ModelScreenUiMode.MANAGING, ModelScreenUiMode.DELETING ->
|
||||
ModelsManagementAndDeletingScreen(
|
||||
filteredModels = filteredModels,
|
||||
isDeleting = currentMode == ModelScreenUiMode.DELETING,
|
||||
onScaffoldEvent = onScaffoldEvent,
|
||||
viewModel = viewModel
|
||||
activeFiltersCount = activeFiltersCount,
|
||||
modelsViewModel = modelsViewModel,
|
||||
managementViewModel = managementViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +103,7 @@ fun ModelsScreen(
|
|||
if (warning.showing) {
|
||||
RamErrorDialog(
|
||||
warning,
|
||||
onDismiss = { viewModel.dismissRamWarning() },
|
||||
onDismiss = { modelsViewModel.dismissRamWarning() },
|
||||
onConfirm = { onConfirmSelection(it.modelInfo, warning) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,15 +39,14 @@ import androidx.compose.ui.unit.dp
|
|||
import com.example.llama.ui.components.ModelCardFullExpandable
|
||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
||||
import com.example.llama.viewmodel.ModelsViewModel
|
||||
import com.example.llama.viewmodel.PreselectedModelToRun
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
@Composable
|
||||
fun ModelsSearchingScreen(
|
||||
|
||||
preselection: PreselectedModelToRun?,
|
||||
viewModel: ModelsViewModel,
|
||||
) {
|
||||
val preselection by viewModel.preselectedModelToRun.collectAsState()
|
||||
|
||||
// Query states
|
||||
val textFieldState = viewModel.searchFieldState
|
||||
val searchQuery by remember(textFieldState) {
|
||||
|
|
@ -68,7 +67,7 @@ fun ModelsSearchingScreen(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// TODO-han.yin: remove after validation
|
||||
// LaunchedEffect (isSearchActive) {
|
||||
// if (isSearchActive) {
|
||||
// toggleSearchFocusAndIme(true)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
package com.example.llama.viewmodel
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Context.RECEIVER_EXPORTED
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.llama.cpp.gguf.InvalidFileFormatException
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.llama.data.model.ModelInfo
|
||||
import com.example.llama.data.repo.InsufficientStorageException
|
||||
import com.example.llama.data.repo.ModelRepository
|
||||
import com.example.llama.data.source.remote.HuggingFaceDownloadInfo
|
||||
import com.example.llama.data.source.remote.HuggingFaceModel
|
||||
import com.example.llama.util.formatFileByteSize
|
||||
import com.example.llama.util.getFileNameFromUri
|
||||
import com.example.llama.util.getFileSizeFromUri
|
||||
import com.example.llama.viewmodel.ModelManagementState.Deletion
|
||||
import com.example.llama.viewmodel.ModelManagementState.Download
|
||||
import com.example.llama.viewmodel.ModelManagementState.Importation
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@HiltViewModel
|
||||
class ModelsManagementViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val modelRepository: ModelRepository,
|
||||
) : ViewModel() {
|
||||
// UI state: models selected to be batch-deleted
|
||||
private val _selectedModelsToDelete = MutableStateFlow<Map<String, ModelInfo>>(emptyMap())
|
||||
val selectedModelsToDelete: StateFlow<Map<String, ModelInfo>> = _selectedModelsToDelete.asStateFlow()
|
||||
|
||||
fun toggleModelSelectionById(filteredModels: List<ModelInfo>, modelId: String) {
|
||||
val current = _selectedModelsToDelete.value.toMutableMap()
|
||||
val model = filteredModels.find { it.id == modelId }
|
||||
|
||||
if (model != null) {
|
||||
if (current.containsKey(modelId)) {
|
||||
current.remove(modelId)
|
||||
} else {
|
||||
current[modelId] = model
|
||||
}
|
||||
_selectedModelsToDelete.value = current
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAllFilteredModelsToDelete(filteredModels: List<ModelInfo>) {
|
||||
_selectedModelsToDelete.value = filteredModels.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun clearAllSelectedModelsToDelete() {
|
||||
_selectedModelsToDelete.value = emptyMap()
|
||||
}
|
||||
|
||||
// UI state: import menu
|
||||
private val _showImportModelMenu = MutableStateFlow(false)
|
||||
val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow()
|
||||
|
||||
fun toggleImportMenu(show: Boolean) {
|
||||
_showImportModelMenu.value = show
|
||||
}
|
||||
|
||||
// HuggingFace: ongoing query jobs
|
||||
private var huggingFaceQueryJob: Job? = null
|
||||
|
||||
// HuggingFace: Ongoing download jobs
|
||||
private val activeDownloads = mutableMapOf<Long, HuggingFaceModel>()
|
||||
private val downloadReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1).let { id ->
|
||||
if (id in activeDownloads) {
|
||||
handleDownloadComplete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal state
|
||||
private val _managementState = MutableStateFlow<ModelManagementState>(ModelManagementState.Idle)
|
||||
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow()
|
||||
|
||||
fun resetManagementState() {
|
||||
huggingFaceQueryJob?.let {
|
||||
if (it.isActive) { it.cancel() }
|
||||
}
|
||||
clearAllSelectedModelsToDelete()
|
||||
_managementState.value = ModelManagementState.Idle
|
||||
}
|
||||
|
||||
init {
|
||||
context.registerReceiver(
|
||||
downloadReceiver,
|
||||
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
|
||||
RECEIVER_EXPORTED
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* First show confirmation instead of starting import local file immediately
|
||||
*/
|
||||
fun importLocalModelFileSelected(uri: Uri) = viewModelScope.launch {
|
||||
try {
|
||||
val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A")
|
||||
val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A")
|
||||
_managementState.value = Importation.Confirming(uri, fileName, fileSize)
|
||||
} catch (e: Exception) {
|
||||
_managementState.value = Importation.Error(
|
||||
message = e.message ?: "Unknown error preparing import"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Import a local model file from device storage while updating UI states with realtime progress
|
||||
*/
|
||||
fun importLocalModelFileConfirmed(uri: Uri, fileName: String, fileSize: Long) = viewModelScope.launch {
|
||||
try {
|
||||
_managementState.value = Importation.Importing(0f, fileName, fileSize)
|
||||
val model = modelRepository.importModel(uri, fileName, fileSize) { progress ->
|
||||
_managementState.value = Importation.Importing(progress, fileName, fileSize)
|
||||
}
|
||||
_managementState.value = Importation.Success(model)
|
||||
} catch (_: InvalidFileFormatException) {
|
||||
_managementState.value = Importation.Error(
|
||||
message = "Not a valid GGUF model!",
|
||||
learnMoreUrl = "https://huggingface.co/docs/hub/en/gguf",
|
||||
)
|
||||
} catch (e: InsufficientStorageException) {
|
||||
_managementState.value = Importation.Error(
|
||||
message = e.message ?: "Insufficient storage space to import $fileName",
|
||||
learnMoreUrl = "https://support.google.com/android/answer/7431795?hl=en",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unknown exception importing $fileName", e)
|
||||
_managementState.value = Importation.Error(
|
||||
message = e.message ?: "Unknown error importing $fileName",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelOngoingLocalModelImport() = viewModelScope.launch {
|
||||
viewModelScope.launch {
|
||||
// First update UI to show we're attempting to cancel
|
||||
_managementState.update { current ->
|
||||
if (current is Importation.Importing) {
|
||||
current.copy(isCancelling = true)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to cancel
|
||||
when (modelRepository.cancelImport()) {
|
||||
null, true -> { _managementState.value = ModelManagementState.Idle }
|
||||
false -> {
|
||||
_managementState.value = Importation.Error(
|
||||
message = "Failed to cancel import. Try again later."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query models on HuggingFace available for download even without signing in
|
||||
*/
|
||||
fun queryModelsFromHuggingFace() {
|
||||
huggingFaceQueryJob = viewModelScope.launch {
|
||||
_managementState.emit(Download.Querying)
|
||||
try {
|
||||
modelRepository.searchHuggingFaceModels().fold(
|
||||
onSuccess = { models ->
|
||||
Log.d(TAG, "Fetched ${models.size} models from HuggingFace:")
|
||||
_managementState.emit(Download.Ready(models))
|
||||
},
|
||||
onFailure = { throw it }
|
||||
)
|
||||
} catch (_: CancellationException) {
|
||||
// no-op
|
||||
} catch (_: UnknownHostException) {
|
||||
_managementState.value = Download.Error(message = "No internet connection")
|
||||
} catch (_: SocketTimeoutException) {
|
||||
_managementState.value = Download.Error(message = "Connection timed out")
|
||||
} catch (_: FileNotFoundException) {
|
||||
_managementState.emit(Download.Error(message = "No eligible models"))
|
||||
} catch (e: IOException) {
|
||||
_managementState.value = Download.Error(message = "Network error: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
_managementState.emit(Download.Error(message = e.message ?: "Unknown error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch download request to [DownloadManager] and update UI
|
||||
*/
|
||||
fun downloadHuggingFaceModelConfirmed(model: HuggingFaceModel) = viewModelScope.launch {
|
||||
try {
|
||||
require(!model.gated) { "Model is gated!" }
|
||||
require(!model.private) { "Model is private!" }
|
||||
val downloadInfo = model.toDownloadInfo()
|
||||
requireNotNull(downloadInfo) { "Download URL is missing!" }
|
||||
|
||||
modelRepository.getHuggingFaceModelFileSize(downloadInfo).fold(
|
||||
onSuccess = { actualSize ->
|
||||
Log.d(TAG, "Model file size: ${formatFileByteSize(actualSize)}")
|
||||
modelRepository.downloadHuggingFaceModel(downloadInfo, actualSize)
|
||||
.onSuccess { downloadId ->
|
||||
activeDownloads[downloadId] = model
|
||||
_managementState.value = Download.Dispatched(downloadInfo)
|
||||
}
|
||||
.onFailure { throw it }
|
||||
},
|
||||
onFailure = { throw it }
|
||||
)
|
||||
} catch (_: UnknownHostException) {
|
||||
_managementState.value = Download.Error(message = "No internet connection")
|
||||
} catch (_: SocketTimeoutException) {
|
||||
_managementState.value = Download.Error(message = "Connection timed out")
|
||||
} catch (e: IOException) {
|
||||
_managementState.value = Download.Error(message = "Network error: ${e.message}")
|
||||
} catch (e: InsufficientStorageException) {
|
||||
_managementState.value = Download.Error(
|
||||
message = e.message ?: "Insufficient storage space to download ${model.modelId}",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_managementState.value = Download.Error(
|
||||
message = e.message ?: "Unknown error downloading ${model.modelId}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDownloadComplete(downloadId: Long) = viewModelScope.launch {
|
||||
val model = activeDownloads.remove(downloadId) ?: return@launch
|
||||
|
||||
(context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager)
|
||||
.getUriForDownloadedFile(downloadId)?.let { uri ->
|
||||
try {
|
||||
val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A")
|
||||
val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A")
|
||||
_managementState.emit(Download.Completed(model, uri, fileName, fileSize))
|
||||
} catch (e: Exception) {
|
||||
_managementState.value = Download.Error(
|
||||
message = e.message ?: "Unknown error downloading ${model.modelId}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First show confirmation instead of starting deletion immediately
|
||||
*/
|
||||
fun batchDeletionClicked(models: Map<String, ModelInfo>) {
|
||||
_managementState.value = Deletion.Confirming(models)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple models one by one while updating UI states with realtime progress
|
||||
*/
|
||||
fun deleteModels(modelsToDelete: Map<String, ModelInfo>) = viewModelScope.launch {
|
||||
val total = modelsToDelete.size
|
||||
if (total == 0) return@launch
|
||||
|
||||
try {
|
||||
_managementState.value = Deletion.Deleting(0f, modelsToDelete)
|
||||
var deleted = 0
|
||||
modelsToDelete.keys.toList().forEach {
|
||||
modelRepository.deleteModel(it)
|
||||
deleted++
|
||||
_managementState.value = Deletion.Deleting(deleted.toFloat() / total, modelsToDelete)
|
||||
}
|
||||
_managementState.value = Deletion.Success(modelsToDelete.values.toList())
|
||||
clearAllSelectedModelsToDelete()
|
||||
|
||||
// Reset state after a delay
|
||||
delay(DELETE_SUCCESS_RESET_TIMEOUT_MS)
|
||||
_managementState.value = ModelManagementState.Idle
|
||||
} catch (e: Exception) {
|
||||
_managementState.value = Deletion.Error(
|
||||
message = e.message ?: "Error deleting $total models"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = ModelsManagementViewModel::class.java.simpleName
|
||||
|
||||
private const val DELETE_SUCCESS_RESET_TIMEOUT_MS = 1000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sealed class ModelManagementState {
|
||||
object Idle : ModelManagementState()
|
||||
|
||||
sealed class Importation : ModelManagementState() {
|
||||
data class Confirming(val uri: Uri, val fileName: String, val fileSize: Long) : Importation()
|
||||
data class Importing(val progress: Float = 0f, val fileName: String, val fileSize: Long, val isCancelling: Boolean = false) : Importation()
|
||||
data class Success(val model: ModelInfo) : Importation()
|
||||
data class Error(val message: String, val learnMoreUrl: String? = null) : Importation()
|
||||
}
|
||||
|
||||
sealed class Download: ModelManagementState() {
|
||||
object Querying : Download()
|
||||
data class Ready(val models: List<HuggingFaceModel>) : Download()
|
||||
data class Dispatched(val downloadInfo: HuggingFaceDownloadInfo) : Download()
|
||||
data class Completed(val model: HuggingFaceModel, val uri: Uri, val fileName: String, val fileSize: Long) : Download()
|
||||
data class Error(val message: String) : Download()
|
||||
}
|
||||
|
||||
sealed class Deletion : ModelManagementState() {
|
||||
data class Confirming(val models: Map<String, ModelInfo>): ModelManagementState()
|
||||
data class Deleting(val progress: Float = 0f, val models: Map<String, ModelInfo>) : ModelManagementState()
|
||||
data class Success(val models: List<ModelInfo>) : Deletion()
|
||||
data class Error(val message: String) : Deletion()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,5 @@
|
|||
package com.example.llama.viewmodel
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Context.RECEIVER_EXPORTED
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.llama.cpp.gguf.InvalidFileFormatException
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
|
|
@ -20,24 +11,12 @@ import com.example.llama.data.model.ModelSortOrder
|
|||
import com.example.llama.data.model.filterBy
|
||||
import com.example.llama.data.model.queryBy
|
||||
import com.example.llama.data.model.sortByOrder
|
||||
import com.example.llama.data.repo.InsufficientStorageException
|
||||
import com.example.llama.data.repo.ModelRepository
|
||||
import com.example.llama.data.source.remote.HuggingFaceDownloadInfo
|
||||
import com.example.llama.data.source.remote.HuggingFaceModel
|
||||
import com.example.llama.engine.InferenceService
|
||||
import com.example.llama.monitoring.PerformanceMonitor
|
||||
import com.example.llama.util.formatFileByteSize
|
||||
import com.example.llama.util.getFileNameFromUri
|
||||
import com.example.llama.util.getFileSizeFromUri
|
||||
import com.example.llama.viewmodel.ModelManagementState.Deletion
|
||||
import com.example.llama.viewmodel.ModelManagementState.Download
|
||||
import com.example.llama.viewmodel.ModelManagementState.Importation
|
||||
import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -48,18 +27,12 @@ import kotlinx.coroutines.flow.debounce
|
|||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@HiltViewModel
|
||||
class ModelsViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val modelRepository: ModelRepository,
|
||||
private val performanceMonitor: PerformanceMonitor,
|
||||
private val inferenceService: InferenceService,
|
||||
|
|
@ -185,65 +158,6 @@ class ModelsViewModel @Inject constructor(
|
|||
initialValue = null
|
||||
)
|
||||
|
||||
// UI state: models selected in deleting mode
|
||||
private val _selectedModelsToDelete = MutableStateFlow<Map<String, ModelInfo>>(emptyMap())
|
||||
val selectedModelsToDelete: StateFlow<Map<String, ModelInfo>> = _selectedModelsToDelete.asStateFlow()
|
||||
|
||||
fun toggleModelSelectionById(modelId: String) {
|
||||
val current = _selectedModelsToDelete.value.toMutableMap()
|
||||
val model = _filteredModels.value.find { it.id == modelId }
|
||||
|
||||
if (model != null) {
|
||||
if (current.containsKey(modelId)) {
|
||||
current.remove(modelId)
|
||||
} else {
|
||||
current[modelId] = model
|
||||
}
|
||||
_selectedModelsToDelete.value = current
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleAllSelectedModelsToDelete(selectAll: Boolean) {
|
||||
if (selectAll) {
|
||||
_selectedModelsToDelete.value = _filteredModels.value.associateBy { it.id }
|
||||
} else {
|
||||
_selectedModelsToDelete.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
// UI state: import menu
|
||||
private val _showImportModelMenu = MutableStateFlow(false)
|
||||
val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow()
|
||||
|
||||
fun toggleImportMenu(show: Boolean) {
|
||||
_showImportModelMenu.value = show
|
||||
}
|
||||
|
||||
// HuggingFace: ongoing query jobs
|
||||
private var huggingFaceQueryJob: Job? = null
|
||||
|
||||
// HuggingFace: Ongoing download jobs
|
||||
private val activeDownloads = mutableMapOf<Long, HuggingFaceModel>()
|
||||
private val downloadReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1).let { id ->
|
||||
if (id in activeDownloads) {
|
||||
handleDownloadComplete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal state
|
||||
private val _managementState = MutableStateFlow<ModelManagementState>(ModelManagementState.Idle)
|
||||
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow()
|
||||
|
||||
fun resetManagementState() {
|
||||
huggingFaceQueryJob?.let {
|
||||
if (it.isActive) { it.cancel() }
|
||||
}
|
||||
_managementState.value = ModelManagementState.Idle
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
|
|
@ -272,9 +186,6 @@ class ModelsViewModel @Inject constructor(
|
|||
_queryResults.value = it
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||
context.registerReceiver(downloadReceiver, filter, RECEIVER_EXPORTED)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -335,193 +246,6 @@ class ModelsViewModel @Inject constructor(
|
|||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* First show confirmation instead of starting import local file immediately
|
||||
*/
|
||||
fun importLocalModelFileSelected(uri: Uri) = viewModelScope.launch {
|
||||
try {
|
||||
val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A")
|
||||
val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A")
|
||||
_managementState.value = Importation.Confirming(uri, fileName, fileSize)
|
||||
} catch (e: Exception) {
|
||||
_managementState.value = Importation.Error(
|
||||
message = e.message ?: "Unknown error preparing import"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Import a local model file from device storage while updating UI states with realtime progress
|
||||
*/
|
||||
fun importLocalModelFileConfirmed(uri: Uri, fileName: String, fileSize: Long) = viewModelScope.launch {
|
||||
try {
|
||||
_managementState.value = Importation.Importing(0f, fileName, fileSize)
|
||||
val model = modelRepository.importModel(uri, fileName, fileSize) { progress ->
|
||||
_managementState.value = Importation.Importing(progress, fileName, fileSize)
|
||||
}
|
||||
_managementState.value = Importation.Success(model)
|
||||
} catch (_: InvalidFileFormatException) {
|
||||
_managementState.value = Importation.Error(
|
||||
message = "Not a valid GGUF model!",
|
||||
learnMoreUrl = "https://huggingface.co/docs/hub/en/gguf",
|
||||
)
|
||||
} catch (e: InsufficientStorageException) {
|
||||
_managementState.value = Importation.Error(
|
||||
message = e.message ?: "Insufficient storage space to import $fileName",
|
||||
learnMoreUrl = "https://support.google.com/android/answer/7431795?hl=en",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unknown exception importing $fileName", e)
|
||||
_managementState.value = Importation.Error(
|
||||
message = e.message ?: "Unknown error importing $fileName",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelOngoingLocalModelImport() = viewModelScope.launch {
|
||||
viewModelScope.launch {
|
||||
// First update UI to show we're attempting to cancel
|
||||
_managementState.update { current ->
|
||||
if (current is Importation.Importing) {
|
||||
current.copy(isCancelling = true)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to cancel
|
||||
when (modelRepository.cancelImport()) {
|
||||
null, true -> { _managementState.value = ModelManagementState.Idle }
|
||||
false -> {
|
||||
_managementState.value = Importation.Error(
|
||||
message = "Failed to cancel import. Try again later."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query models on HuggingFace available for download even without signing in
|
||||
*/
|
||||
fun queryModelsFromHuggingFace() {
|
||||
huggingFaceQueryJob = viewModelScope.launch {
|
||||
_managementState.emit(Download.Querying)
|
||||
try {
|
||||
modelRepository.searchHuggingFaceModels().fold(
|
||||
onSuccess = { models ->
|
||||
Log.d(TAG, "Fetched ${models.size} models from HuggingFace:")
|
||||
_managementState.emit(Download.Ready(models))
|
||||
},
|
||||
onFailure = { throw it }
|
||||
)
|
||||
} catch (_: CancellationException) {
|
||||
// no-op
|
||||
} catch (_: UnknownHostException) {
|
||||
_managementState.value = Download.Error(message = "No internet connection")
|
||||
} catch (_: SocketTimeoutException) {
|
||||
_managementState.value = Download.Error(message = "Connection timed out")
|
||||
} catch (_: FileNotFoundException) {
|
||||
_managementState.emit(Download.Error(message = "No eligible models"))
|
||||
} catch (e: IOException) {
|
||||
_managementState.value = Download.Error(message = "Network error: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
_managementState.emit(Download.Error(message = e.message ?: "Unknown error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch download request to [DownloadManager] and update UI
|
||||
*/
|
||||
fun downloadHuggingFaceModelConfirmed(model: HuggingFaceModel) = viewModelScope.launch {
|
||||
try {
|
||||
require(!model.gated) { "Model is gated!" }
|
||||
require(!model.private) { "Model is private!" }
|
||||
val downloadInfo = model.toDownloadInfo()
|
||||
requireNotNull(downloadInfo) { "Download URL is missing!" }
|
||||
|
||||
modelRepository.getHuggingFaceModelFileSize(downloadInfo).fold(
|
||||
onSuccess = { actualSize ->
|
||||
Log.d(TAG, "Model file size: ${formatFileByteSize(actualSize)}")
|
||||
modelRepository.downloadHuggingFaceModel(downloadInfo, actualSize)
|
||||
.onSuccess { downloadId ->
|
||||
activeDownloads[downloadId] = model
|
||||
_managementState.value = Download.Dispatched(downloadInfo)
|
||||
}
|
||||
.onFailure { throw it }
|
||||
},
|
||||
onFailure = { throw it }
|
||||
)
|
||||
} catch (_: UnknownHostException) {
|
||||
_managementState.value = Download.Error(message = "No internet connection")
|
||||
} catch (_: SocketTimeoutException) {
|
||||
_managementState.value = Download.Error(message = "Connection timed out")
|
||||
} catch (e: IOException) {
|
||||
_managementState.value = Download.Error(message = "Network error: ${e.message}")
|
||||
} catch (e: InsufficientStorageException) {
|
||||
_managementState.value = Download.Error(
|
||||
message = e.message ?: "Insufficient storage space to download ${model.modelId}",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_managementState.value = Download.Error(
|
||||
message = e.message ?: "Unknown error downloading ${model.modelId}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDownloadComplete(downloadId: Long) = viewModelScope.launch {
|
||||
val model = activeDownloads.remove(downloadId) ?: return@launch
|
||||
|
||||
(context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager)
|
||||
.getUriForDownloadedFile(downloadId)?.let { uri ->
|
||||
try {
|
||||
val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A")
|
||||
val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A")
|
||||
_managementState.emit(Download.Completed(model, uri, fileName, fileSize))
|
||||
} catch (e: Exception) {
|
||||
_managementState.value = Download.Error(
|
||||
message = e.message ?: "Unknown error downloading ${model.modelId}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First show confirmation instead of starting deletion immediately
|
||||
*/
|
||||
fun batchDeletionClicked(models: Map<String, ModelInfo>) {
|
||||
_managementState.value = Deletion.Confirming(models)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple models one by one while updating UI states with realtime progress
|
||||
*/
|
||||
fun deleteModels(models: Map<String, ModelInfo>) = viewModelScope.launch {
|
||||
val total = models.size
|
||||
if (total == 0) return@launch
|
||||
|
||||
try {
|
||||
_managementState.value = Deletion.Deleting(0f, models)
|
||||
var deleted = 0
|
||||
models.keys.toList().forEach {
|
||||
modelRepository.deleteModel(it)
|
||||
deleted++
|
||||
_managementState.value = Deletion.Deleting(deleted.toFloat() / total, models)
|
||||
}
|
||||
_managementState.value = Deletion.Success(models.values.toList())
|
||||
toggleAllSelectedModelsToDelete(false)
|
||||
|
||||
// Reset state after a delay
|
||||
delay(DELETE_SUCCESS_RESET_TIMEOUT_MS)
|
||||
_managementState.value = ModelManagementState.Idle
|
||||
} catch (e: Exception) {
|
||||
_managementState.value = Deletion.Error(
|
||||
message = e.message ?: "Error deleting $total models"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = ModelsViewModel::class.java.simpleName
|
||||
|
|
@ -529,8 +253,6 @@ class ModelsViewModel @Inject constructor(
|
|||
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
|
||||
private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L
|
||||
|
||||
private const val DELETE_SUCCESS_RESET_TIMEOUT_MS = 1000L
|
||||
|
||||
private const val RAM_LOAD_MODEL_BUFFER_BYTES = 300 * 1024
|
||||
}
|
||||
}
|
||||
|
|
@ -552,29 +274,3 @@ data class PreselectedModelToRun(
|
|||
val showing: Boolean,
|
||||
)
|
||||
}
|
||||
|
||||
sealed class ModelManagementState {
|
||||
object Idle : ModelManagementState()
|
||||
|
||||
sealed class Importation : ModelManagementState() {
|
||||
data class Confirming(val uri: Uri, val fileName: String, val fileSize: Long) : Importation()
|
||||
data class Importing(val progress: Float = 0f, val fileName: String, val fileSize: Long, val isCancelling: Boolean = false) : Importation()
|
||||
data class Success(val model: ModelInfo) : Importation()
|
||||
data class Error(val message: String, val learnMoreUrl: String? = null) : Importation()
|
||||
}
|
||||
|
||||
sealed class Download: ModelManagementState() {
|
||||
object Querying : Download()
|
||||
data class Ready(val models: List<HuggingFaceModel>) : Download()
|
||||
data class Dispatched(val downloadInfo: HuggingFaceDownloadInfo) : Download()
|
||||
data class Completed(val model: HuggingFaceModel, val uri: Uri, val fileName: String, val fileSize: Long) : Download()
|
||||
data class Error(val message: String) : Download()
|
||||
}
|
||||
|
||||
sealed class Deletion : ModelManagementState() {
|
||||
data class Confirming(val models: Map<String, ModelInfo>): ModelManagementState()
|
||||
data class Deleting(val progress: Float = 0f, val models: Map<String, ModelInfo>) : ModelManagementState()
|
||||
data class Success(val models: List<ModelInfo>) : Deletion()
|
||||
data class Error(val message: String) : Deletion()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue