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.ConversationViewModel
|
||||||
import com.example.llama.viewmodel.MainViewModel
|
import com.example.llama.viewmodel.MainViewModel
|
||||||
import com.example.llama.viewmodel.ModelLoadingViewModel
|
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.ModelsViewModel
|
||||||
import com.example.llama.viewmodel.SettingsViewModel
|
import com.example.llama.viewmodel.SettingsViewModel
|
||||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -85,10 +86,10 @@ fun AppContent(
|
||||||
settingsViewModel: SettingsViewModel,
|
settingsViewModel: SettingsViewModel,
|
||||||
mainViewModel: MainViewModel = hiltViewModel(),
|
mainViewModel: MainViewModel = hiltViewModel(),
|
||||||
modelsViewModel: ModelsViewModel = hiltViewModel(),
|
modelsViewModel: ModelsViewModel = hiltViewModel(),
|
||||||
|
modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
|
||||||
modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(),
|
modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(),
|
||||||
benchmarkViewModel: BenchmarkViewModel = hiltViewModel(),
|
benchmarkViewModel: BenchmarkViewModel = hiltViewModel(),
|
||||||
conversationViewModel: ConversationViewModel = hiltViewModel(),
|
conversationViewModel: ConversationViewModel = hiltViewModel(),
|
||||||
// modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
|
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
@ -191,19 +192,20 @@ fun AppContent(
|
||||||
// Model selection screen
|
// Model selection screen
|
||||||
currentRoute == AppDestinations.MODELS_ROUTE -> {
|
currentRoute == AppDestinations.MODELS_ROUTE -> {
|
||||||
// Collect states for bottom bar
|
// Collect states for bottom bar
|
||||||
|
val filteredModels by modelsViewModel.filteredModels.collectAsState()
|
||||||
val sortOrder by modelsViewModel.sortOrder.collectAsState()
|
val sortOrder by modelsViewModel.sortOrder.collectAsState()
|
||||||
val showSortMenu by modelsViewModel.showSortMenu.collectAsState()
|
val showSortMenu by modelsViewModel.showSortMenu.collectAsState()
|
||||||
val activeFilters by modelsViewModel.activeFilters.collectAsState()
|
val activeFilters by modelsViewModel.activeFilters.collectAsState()
|
||||||
val showFilterMenu by modelsViewModel.showFilterMenu.collectAsState()
|
val showFilterMenu by modelsViewModel.showFilterMenu.collectAsState()
|
||||||
val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
|
val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
|
||||||
|
|
||||||
val selectedModelsToDelete by modelsViewModel.selectedModelsToDelete.collectAsState()
|
val selectedModelsToDelete by modelsManagementViewModel.selectedModelsToDelete.collectAsState()
|
||||||
val showImportModelMenu by modelsViewModel.showImportModelMenu.collectAsState()
|
val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState()
|
||||||
|
|
||||||
// Create file launcher for importing local models
|
// Create file launcher for importing local models
|
||||||
val fileLauncher = rememberLauncherForActivityResult(
|
val fileLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocument()
|
contract = ActivityResultContracts.OpenDocument()
|
||||||
) { uri -> uri?.let { modelsViewModel.importLocalModelFileSelected(it) } }
|
) { uri -> uri?.let { modelsManagementViewModel.importLocalModelFileSelected(it) } }
|
||||||
|
|
||||||
ScaffoldConfig(
|
ScaffoldConfig(
|
||||||
topBarConfig =
|
topBarConfig =
|
||||||
|
|
@ -231,6 +233,7 @@ fun AppContent(
|
||||||
TopBarConfig.ModelsDeleting(
|
TopBarConfig.ModelsDeleting(
|
||||||
title = "Deleting models",
|
title = "Deleting models",
|
||||||
navigationIcon = NavigationIcon.Quit {
|
navigationIcon = NavigationIcon.Quit {
|
||||||
|
modelsManagementViewModel.resetManagementState()
|
||||||
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -309,14 +312,14 @@ fun AppContent(
|
||||||
),
|
),
|
||||||
importing = BottomBarConfig.Models.Management.ImportConfig(
|
importing = BottomBarConfig.Models.Management.ImportConfig(
|
||||||
isMenuVisible = showImportModelMenu,
|
isMenuVisible = showImportModelMenu,
|
||||||
toggleMenu = { show -> modelsViewModel.toggleImportMenu(show) },
|
toggleMenu = { show -> modelsManagementViewModel.toggleImportMenu(show) },
|
||||||
importFromLocal = {
|
importFromLocal = {
|
||||||
fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
|
fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
|
||||||
modelsViewModel.toggleImportMenu(false)
|
modelsManagementViewModel.toggleImportMenu(false)
|
||||||
},
|
},
|
||||||
importFromHuggingFace = {
|
importFromHuggingFace = {
|
||||||
modelsViewModel.queryModelsFromHuggingFace()
|
modelsManagementViewModel.queryModelsFromHuggingFace()
|
||||||
modelsViewModel.toggleImportMenu(false)
|
modelsManagementViewModel.toggleImportMenu(false)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
onToggleDeleting = {
|
onToggleDeleting = {
|
||||||
|
|
@ -330,11 +333,16 @@ fun AppContent(
|
||||||
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||||
},
|
},
|
||||||
selectedModels = selectedModelsToDelete,
|
selectedModels = selectedModelsToDelete,
|
||||||
toggleAllSelection = { modelsViewModel.toggleAllSelectedModelsToDelete(it) },
|
selectAllFilteredModels = {
|
||||||
|
modelsManagementViewModel.selectAllFilteredModelsToDelete(filteredModels)
|
||||||
|
},
|
||||||
|
clearAllSelectedModels = {
|
||||||
|
modelsManagementViewModel.clearAllSelectedModelsToDelete()
|
||||||
|
},
|
||||||
deleteSelected = {
|
deleteSelected = {
|
||||||
selectedModelsToDelete.let {
|
selectedModelsToDelete.let {
|
||||||
if (it.isNotEmpty()) {
|
if (it.isNotEmpty()) {
|
||||||
modelsViewModel.batchDeletionClicked(it)
|
modelsManagementViewModel.batchDeletionClicked(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -464,7 +472,8 @@ fun AppContent(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onScaffoldEvent = handleScaffoldEvent,
|
onScaffoldEvent = handleScaffoldEvent,
|
||||||
viewModel = modelsViewModel
|
modelsViewModel = modelsViewModel,
|
||||||
|
managementViewModel = modelsManagementViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.example.llama.ui.scaffold
|
package com.example.llama.ui.scaffold
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
|
@ -77,6 +78,8 @@ fun AppScaffold(
|
||||||
|
|
||||||
is TopBarConfig.ModelsDeleting -> DefaultTopBar(
|
is TopBarConfig.ModelsDeleting -> DefaultTopBar(
|
||||||
title = topBarconfig.title,
|
title = topBarconfig.title,
|
||||||
|
titleColor = MaterialTheme.colorScheme.error,
|
||||||
|
navigationIconTint = MaterialTheme.colorScheme.error,
|
||||||
onQuit = topBarconfig.navigationIcon.quitAction
|
onQuit = topBarconfig.navigationIcon.quitAction
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,8 @@ sealed class BottomBarConfig {
|
||||||
data class Deleting(
|
data class Deleting(
|
||||||
val onQuitDeleting: () -> Unit,
|
val onQuitDeleting: () -> Unit,
|
||||||
val selectedModels: Map<String, ModelInfo>,
|
val selectedModels: Map<String, ModelInfo>,
|
||||||
val toggleAllSelection: (Boolean) -> Unit,
|
val selectAllFilteredModels: () -> Unit,
|
||||||
|
val clearAllSelectedModels: () -> Unit,
|
||||||
val deleteSelected: () -> Unit
|
val deleteSelected: () -> Unit
|
||||||
) : BottomBarConfig()
|
) : BottomBarConfig()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,14 @@ fun ModelsDeletingBottomBar(
|
||||||
) {
|
) {
|
||||||
BottomAppBar(
|
BottomAppBar(
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { deleting.toggleAllSelection(false) }) {
|
IconButton(onClick = { deleting.clearAllSelectedModels() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ClearAll,
|
imageVector = Icons.Default.ClearAll,
|
||||||
contentDescription = "Deselect all"
|
contentDescription = "Deselect all"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(onClick = { deleting.toggleAllSelection(true) }) {
|
IconButton(onClick = { deleting.selectAllFilteredModels() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.SelectAll,
|
imageVector = Icons.Default.SelectAll,
|
||||||
contentDescription = "Select all"
|
contentDescription = "Select all"
|
||||||
|
|
|
||||||
|
|
@ -12,24 +12,30 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DefaultTopBar(
|
fun DefaultTopBar(
|
||||||
title: String,
|
title: String,
|
||||||
|
titleColor: Color = Color.Unspecified,
|
||||||
|
navigationIconTint: Color = Color.Unspecified,
|
||||||
onNavigateBack: (() -> Unit)? = null,
|
onNavigateBack: (() -> Unit)? = null,
|
||||||
onQuit: (() -> Unit)? = null,
|
onQuit: (() -> Unit)? = null,
|
||||||
onMenuOpen: (() -> Unit)? = null
|
onMenuOpen: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(title) },
|
title = {
|
||||||
|
Text(text = title, color = titleColor)
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
when {
|
when {
|
||||||
onQuit != null -> {
|
onQuit != null -> {
|
||||||
IconButton(onClick = onQuit) {
|
IconButton(onClick = onQuit) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = "Quit"
|
contentDescription = "Quit",
|
||||||
|
tint = navigationIconTint
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -38,7 +44,8 @@ fun DefaultTopBar(
|
||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = "Back"
|
contentDescription = "Back",
|
||||||
|
tint = navigationIconTint
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +54,8 @@ fun DefaultTopBar(
|
||||||
IconButton(onClick = onMenuOpen) {
|
IconButton(onClick = onMenuOpen) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Menu,
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.FolderOpen
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
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.viewmodel.ModelsViewModel
|
import com.example.llama.viewmodel.ModelsViewModel
|
||||||
|
import com.example.llama.viewmodel.PreselectedModelToRun
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ModelsBrowsingScreen(
|
fun ModelsBrowsingScreen(
|
||||||
|
filteredModels: List<ModelInfo>,
|
||||||
|
activeFiltersCount: Int,
|
||||||
|
preselection: PreselectedModelToRun?,
|
||||||
onManageModelsClicked: () -> Unit,
|
onManageModelsClicked: () -> Unit,
|
||||||
viewModel: ModelsViewModel,
|
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()) {
|
if (filteredModels.isEmpty()) {
|
||||||
// Empty model prompt
|
// Empty model prompt
|
||||||
EmptyModelsView(activeFiltersCount, onManageModelsClicked)
|
EmptyModelsView(activeFiltersCount, onManageModelsClicked)
|
||||||
|
|
@ -63,7 +53,6 @@ fun ModelsBrowsingScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyModelsView(
|
private fun EmptyModelsView(
|
||||||
activeFiltersCount: Int,
|
activeFiltersCount: Int,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ 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
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.unit.dp
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
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.source.remote.HuggingFaceModel
|
import com.example.llama.data.source.remote.HuggingFaceModel
|
||||||
import com.example.llama.ui.components.InfoAction
|
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.Download
|
||||||
import com.example.llama.viewmodel.ModelManagementState.Importation
|
import com.example.llama.viewmodel.ModelManagementState.Importation
|
||||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
import com.example.llama.viewmodel.ModelScreenUiMode
|
||||||
|
import com.example.llama.viewmodel.ModelsManagementViewModel
|
||||||
import com.example.llama.viewmodel.ModelsViewModel
|
import com.example.llama.viewmodel.ModelsViewModel
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
@ -77,24 +78,18 @@ import java.util.Locale
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ModelsManagementAndDeletingScreen(
|
fun ModelsManagementAndDeletingScreen(
|
||||||
|
filteredModels: List<ModelInfo>,
|
||||||
|
activeFiltersCount: Int,
|
||||||
isDeleting: Boolean,
|
isDeleting: Boolean,
|
||||||
onScaffoldEvent: (ScaffoldEvent) -> Unit,
|
onScaffoldEvent: (ScaffoldEvent) -> Unit,
|
||||||
viewModel: ModelsViewModel,
|
modelsViewModel: ModelsViewModel,
|
||||||
|
managementViewModel: ModelsManagementViewModel,
|
||||||
) {
|
) {
|
||||||
// Data: models
|
|
||||||
val filteredModels by viewModel.filteredModels.collectAsState()
|
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
val selectedModels by viewModel.selectedModelsToDelete.collectAsState()
|
val selectedModels by managementViewModel.selectedModelsToDelete.collectAsState()
|
||||||
|
|
||||||
// Filter state
|
|
||||||
val activeFilters by viewModel.activeFilters.collectAsState()
|
|
||||||
val activeFiltersCount by remember(activeFilters) {
|
|
||||||
derivedStateOf { activeFilters.count { it.value } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model management state
|
// Model management state
|
||||||
val managementState by viewModel.managementState.collectAsState()
|
val managementState by managementViewModel.managementState.collectAsState()
|
||||||
|
|
||||||
// UI states
|
// UI states
|
||||||
val expandedModels = remember { mutableStateMapOf<String, ModelInfo>() }
|
val expandedModels = remember { mutableStateMapOf<String, ModelInfo>() }
|
||||||
|
|
@ -136,7 +131,7 @@ fun ModelsManagementAndDeletingScreen(
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
onSelected = {
|
onSelected = {
|
||||||
if (isDeleting) {
|
if (isDeleting) {
|
||||||
viewModel.toggleModelSelectionById(model.id)
|
managementViewModel.toggleModelSelectionById(filteredModels, model.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isExpanded = expandedModels.contains(model.id),
|
isExpanded = expandedModels.contains(model.id),
|
||||||
|
|
@ -161,11 +156,11 @@ fun ModelsManagementAndDeletingScreen(
|
||||||
isImporting = false,
|
isImporting = false,
|
||||||
progress = 0.0f,
|
progress = 0.0f,
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
viewModel.importLocalModelFileConfirmed(
|
managementViewModel.importLocalModelFileConfirmed(
|
||||||
state.uri, state.fileName, state.fileSize
|
state.uri, state.fileName, state.fileSize
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onCancel = { viewModel.resetManagementState() }
|
onCancel = { managementViewModel.resetManagementState() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,7 +172,7 @@ fun ModelsManagementAndDeletingScreen(
|
||||||
isCancelling = state.isCancelling,
|
isCancelling = state.isCancelling,
|
||||||
progress = state.progress,
|
progress = state.progress,
|
||||||
onConfirm = {},
|
onConfirm = {},
|
||||||
onCancel = { viewModel.cancelOngoingLocalModelImport() },
|
onCancel = { managementViewModel.cancelOngoingLocalModelImport() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,7 +181,7 @@ fun ModelsManagementAndDeletingScreen(
|
||||||
title = "Import Failed",
|
title = "Import Failed",
|
||||||
message = state.message,
|
message = state.message,
|
||||||
learnMoreUrl = state.learnMoreUrl,
|
learnMoreUrl = state.learnMoreUrl,
|
||||||
onDismiss = { viewModel.resetManagementState() }
|
onDismiss = { managementViewModel.resetManagementState() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,21 +193,21 @@ fun ModelsManagementAndDeletingScreen(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.resetManagementState()
|
managementViewModel.resetManagementState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is Download.Querying -> {
|
is Download.Querying -> {
|
||||||
ImportFromHuggingFaceDialog(
|
ImportFromHuggingFaceDialog(
|
||||||
onCancel = { viewModel.resetManagementState() }
|
onCancel = { managementViewModel.resetManagementState() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is Download.Ready -> {
|
is Download.Ready -> {
|
||||||
ImportFromHuggingFaceDialog(
|
ImportFromHuggingFaceDialog(
|
||||||
models = state.models,
|
models = state.models,
|
||||||
onConfirm = { viewModel.downloadHuggingFaceModelConfirmed(it) },
|
onConfirm = { managementViewModel.downloadHuggingFaceModelConfirmed(it) },
|
||||||
onCancel = { viewModel.resetManagementState() }
|
onCancel = { managementViewModel.resetManagementState() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,7 +220,7 @@ fun ModelsManagementAndDeletingScreen(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.resetManagementState()
|
managementViewModel.resetManagementState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,11 +231,11 @@ fun ModelsManagementAndDeletingScreen(
|
||||||
isImporting = false,
|
isImporting = false,
|
||||||
progress = 0.0f,
|
progress = 0.0f,
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
viewModel.importLocalModelFileConfirmed(
|
managementViewModel.importLocalModelFileConfirmed(
|
||||||
state.uri, state.fileName, state.fileSize
|
state.uri, state.fileName, state.fileSize
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onCancel = { viewModel.resetManagementState() }
|
onCancel = { managementViewModel.resetManagementState() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,15 +243,15 @@ fun ModelsManagementAndDeletingScreen(
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
title = "Download Failed",
|
title = "Download Failed",
|
||||||
message = state.message,
|
message = state.message,
|
||||||
onDismiss = { viewModel.resetManagementState() }
|
onDismiss = { managementViewModel.resetManagementState() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is Deletion.Confirming -> {
|
is Deletion.Confirming -> {
|
||||||
BatchDeleteConfirmationDialog(
|
BatchDeleteConfirmationDialog(
|
||||||
count = state.models.size,
|
count = state.models.size,
|
||||||
onConfirm = { viewModel.deleteModels(state.models) },
|
onConfirm = { managementViewModel.deleteModels(state.models) },
|
||||||
onDismiss = { viewModel.resetManagementState() },
|
onDismiss = { managementViewModel.resetManagementState() },
|
||||||
isDeleting = false
|
isDeleting = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -274,19 +269,20 @@ fun ModelsManagementAndDeletingScreen(
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
title = "Deletion Failed",
|
title = "Deletion Failed",
|
||||||
message = state.message,
|
message = state.message,
|
||||||
onDismiss = { viewModel.resetManagementState() }
|
onDismiss = { managementViewModel.resetManagementState() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is Deletion.Success -> {
|
is Deletion.Success -> {
|
||||||
LaunchedEffect(state) {
|
LaunchedEffect(state) {
|
||||||
viewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||||
|
|
||||||
val count = state.models.size
|
val count = state.models.size
|
||||||
onScaffoldEvent(
|
onScaffoldEvent(
|
||||||
ScaffoldEvent.ShowSnackbar(
|
ScaffoldEvent.ShowSnackbar(
|
||||||
message = "Deleted $count ${if (count > 1) "models" else "model"}.",
|
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.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.example.llama.data.model.ModelInfo
|
import com.example.llama.data.model.ModelInfo
|
||||||
import com.example.llama.ui.components.InfoView
|
import com.example.llama.ui.components.InfoView
|
||||||
import com.example.llama.ui.scaffold.ScaffoldEvent
|
import com.example.llama.ui.scaffold.ScaffoldEvent
|
||||||
import com.example.llama.util.formatFileByteSize
|
import com.example.llama.util.formatFileByteSize
|
||||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
import com.example.llama.viewmodel.ModelScreenUiMode
|
||||||
|
import com.example.llama.viewmodel.ModelsManagementViewModel
|
||||||
import com.example.llama.viewmodel.ModelsViewModel
|
import com.example.llama.viewmodel.ModelsViewModel
|
||||||
import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
|
import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
|
||||||
|
|
||||||
|
|
@ -29,31 +32,39 @@ fun ModelsScreen(
|
||||||
onManageModelsClicked: () -> Unit,
|
onManageModelsClicked: () -> Unit,
|
||||||
onConfirmSelection: (ModelInfo, RamWarning) -> Unit,
|
onConfirmSelection: (ModelInfo, RamWarning) -> Unit,
|
||||||
onScaffoldEvent: (ScaffoldEvent) -> Unit,
|
onScaffoldEvent: (ScaffoldEvent) -> Unit,
|
||||||
viewModel: ModelsViewModel,
|
modelsViewModel: ModelsViewModel,
|
||||||
|
managementViewModel: ModelsManagementViewModel,
|
||||||
) {
|
) {
|
||||||
// Data
|
// 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
|
// UI states
|
||||||
val currentMode by viewModel.modelScreenUiMode.collectAsState()
|
val currentMode by modelsViewModel.modelScreenUiMode.collectAsState()
|
||||||
|
|
||||||
// Handle back button press
|
// Handle back button press
|
||||||
BackHandler {
|
BackHandler {
|
||||||
when (currentMode) {
|
when (currentMode) {
|
||||||
ModelScreenUiMode.BROWSING -> {
|
ModelScreenUiMode.BROWSING -> {
|
||||||
if (preselection != null) {
|
if (preselection != null) {
|
||||||
viewModel.resetPreselection()
|
modelsViewModel.resetPreselection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ModelScreenUiMode.SEARCHING -> {
|
ModelScreenUiMode.SEARCHING -> {
|
||||||
viewModel.toggleMode(ModelScreenUiMode.BROWSING)
|
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
|
||||||
}
|
}
|
||||||
ModelScreenUiMode.MANAGING -> {
|
ModelScreenUiMode.MANAGING -> {
|
||||||
viewModel.toggleMode(ModelScreenUiMode.BROWSING)
|
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
|
||||||
}
|
}
|
||||||
ModelScreenUiMode.DELETING -> {
|
ModelScreenUiMode.DELETING -> {
|
||||||
viewModel.toggleAllSelectedModelsToDelete(false)
|
managementViewModel.clearAllSelectedModelsToDelete()
|
||||||
viewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -64,16 +75,25 @@ fun ModelsScreen(
|
||||||
when (currentMode) {
|
when (currentMode) {
|
||||||
ModelScreenUiMode.BROWSING ->
|
ModelScreenUiMode.BROWSING ->
|
||||||
ModelsBrowsingScreen(
|
ModelsBrowsingScreen(
|
||||||
|
filteredModels = filteredModels,
|
||||||
|
preselection = preselection,
|
||||||
onManageModelsClicked = { /* TODO-han.yin */ },
|
onManageModelsClicked = { /* TODO-han.yin */ },
|
||||||
viewModel = viewModel
|
activeFiltersCount = activeFiltersCount,
|
||||||
|
viewModel = modelsViewModel,
|
||||||
)
|
)
|
||||||
ModelScreenUiMode.SEARCHING ->
|
ModelScreenUiMode.SEARCHING ->
|
||||||
ModelsSearchingScreen(viewModel = viewModel)
|
ModelsSearchingScreen(
|
||||||
|
preselection = preselection,
|
||||||
|
viewModel = modelsViewModel
|
||||||
|
)
|
||||||
ModelScreenUiMode.MANAGING, ModelScreenUiMode.DELETING ->
|
ModelScreenUiMode.MANAGING, ModelScreenUiMode.DELETING ->
|
||||||
ModelsManagementAndDeletingScreen(
|
ModelsManagementAndDeletingScreen(
|
||||||
|
filteredModels = filteredModels,
|
||||||
isDeleting = currentMode == ModelScreenUiMode.DELETING,
|
isDeleting = currentMode == ModelScreenUiMode.DELETING,
|
||||||
onScaffoldEvent = onScaffoldEvent,
|
onScaffoldEvent = onScaffoldEvent,
|
||||||
viewModel = viewModel
|
activeFiltersCount = activeFiltersCount,
|
||||||
|
modelsViewModel = modelsViewModel,
|
||||||
|
managementViewModel = managementViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,7 +103,7 @@ fun ModelsScreen(
|
||||||
if (warning.showing) {
|
if (warning.showing) {
|
||||||
RamErrorDialog(
|
RamErrorDialog(
|
||||||
warning,
|
warning,
|
||||||
onDismiss = { viewModel.dismissRamWarning() },
|
onDismiss = { modelsViewModel.dismissRamWarning() },
|
||||||
onConfirm = { onConfirmSelection(it.modelInfo, warning) }
|
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.ui.components.ModelCardFullExpandable
|
||||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
import com.example.llama.viewmodel.ModelScreenUiMode
|
||||||
import com.example.llama.viewmodel.ModelsViewModel
|
import com.example.llama.viewmodel.ModelsViewModel
|
||||||
|
import com.example.llama.viewmodel.PreselectedModelToRun
|
||||||
|
|
||||||
@ExperimentalMaterial3Api
|
@ExperimentalMaterial3Api
|
||||||
@Composable
|
@Composable
|
||||||
fun ModelsSearchingScreen(
|
fun ModelsSearchingScreen(
|
||||||
|
preselection: PreselectedModelToRun?,
|
||||||
viewModel: ModelsViewModel,
|
viewModel: ModelsViewModel,
|
||||||
) {
|
) {
|
||||||
val preselection by viewModel.preselectedModelToRun.collectAsState()
|
|
||||||
|
|
||||||
// Query states
|
// Query states
|
||||||
val textFieldState = viewModel.searchFieldState
|
val textFieldState = viewModel.searchFieldState
|
||||||
val searchQuery by remember(textFieldState) {
|
val searchQuery by remember(textFieldState) {
|
||||||
|
|
@ -68,7 +67,7 @@ fun ModelsSearchingScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO-han.yin: remove after validation
|
||||||
// LaunchedEffect (isSearchActive) {
|
// LaunchedEffect (isSearchActive) {
|
||||||
// if (isSearchActive) {
|
// if (isSearchActive) {
|
||||||
// toggleSearchFocusAndIme(true)
|
// 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
|
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.TextFieldState
|
||||||
import androidx.compose.foundation.text.input.clearText
|
import androidx.compose.foundation.text.input.clearText
|
||||||
import androidx.compose.runtime.snapshotFlow
|
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.filterBy
|
||||||
import com.example.llama.data.model.queryBy
|
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.InsufficientStorageException
|
|
||||||
import com.example.llama.data.repo.ModelRepository
|
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.engine.InferenceService
|
||||||
import com.example.llama.monitoring.PerformanceMonitor
|
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 com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -48,18 +27,12 @@ import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
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 javax.inject.Inject
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
|
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ModelsViewModel @Inject constructor(
|
class ModelsViewModel @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val modelRepository: ModelRepository,
|
private val modelRepository: ModelRepository,
|
||||||
private val performanceMonitor: PerformanceMonitor,
|
private val performanceMonitor: PerformanceMonitor,
|
||||||
private val inferenceService: InferenceService,
|
private val inferenceService: InferenceService,
|
||||||
|
|
@ -185,65 +158,6 @@ class ModelsViewModel @Inject constructor(
|
||||||
initialValue = null
|
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 {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -272,9 +186,6 @@ class ModelsViewModel @Inject constructor(
|
||||||
_queryResults.value = it
|
_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
|
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 {
|
companion object {
|
||||||
private val TAG = ModelsViewModel::class.java.simpleName
|
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 SUBSCRIPTION_TIMEOUT_MS = 5000L
|
||||||
private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L
|
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
|
private const val RAM_LOAD_MODEL_BUFFER_BYTES = 300 * 1024
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -552,29 +274,3 @@ data class PreselectedModelToRun(
|
||||||
val showing: Boolean,
|
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