UI: replace Models Management screen's stubbing with instrumentation
This commit is contained in:
parent
bc93c384a7
commit
760d66c97d
|
|
@ -2,18 +2,30 @@ package com.example.llama.revamp.data.repository
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.StatFs
|
||||
import android.provider.OpenableColumns
|
||||
import com.example.llama.revamp.data.local.ModelDao
|
||||
import com.example.llama.revamp.data.local.ModelEntity
|
||||
import com.example.llama.revamp.data.model.ModelInfo
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Repository for managing available models.
|
||||
* Repository for managing available models on local device.
|
||||
*/
|
||||
interface ModelRepository {
|
||||
// TODO-han.yin: change to Flow for getters
|
||||
suspend fun getStorageMetrics(): StorageMetrics
|
||||
suspend fun getModels(): List<ModelInfo>
|
||||
fun getStorageMetrics(): Flow<StorageMetrics>
|
||||
fun getModels(): Flow<List<ModelInfo>>
|
||||
|
||||
suspend fun importModel(uri: Uri): ModelInfo
|
||||
suspend fun deleteModel(modelId: String)
|
||||
|
|
@ -23,40 +35,163 @@ interface ModelRepository {
|
|||
@Singleton
|
||||
class ModelRepositoryImpl @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
// TODO-han.yin: Add model DAO
|
||||
private val modelDao: ModelDao,
|
||||
) : ModelRepository {
|
||||
|
||||
override suspend fun getStorageMetrics(): StorageMetrics {
|
||||
// Stub - would calculate from actual storage
|
||||
return StorageMetrics(14.6f, 32.0f)
|
||||
private val modelsDir = File(context.filesDir, INTERNAL_STORAGE_PATH)
|
||||
|
||||
init {
|
||||
if (!modelsDir.exists()) { modelsDir.mkdirs() }
|
||||
}
|
||||
|
||||
override suspend fun getModels(): List<ModelInfo> {
|
||||
// In a real implementation, this would load from database
|
||||
return ModelInfo.getSampleModels()
|
||||
override fun getStorageMetrics(): Flow<StorageMetrics> = flow {
|
||||
while (true) {
|
||||
emit(
|
||||
StorageMetrics(
|
||||
usedGB = modelsSizeBytes / BYTES_IN_GB,
|
||||
totalGB = availableSpaceBytes / BYTES_IN_GB
|
||||
)
|
||||
)
|
||||
delay(STORAGE_METRICS_UPDATE_INTERVAL)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getModels(): Flow<List<ModelInfo>> =
|
||||
modelDao.getAllModels()
|
||||
.map { entities ->
|
||||
entities.filter {
|
||||
val file = File(it.path)
|
||||
file.exists() && file.isFile
|
||||
}.map {
|
||||
it.toModelInfo()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun importModel(uri: Uri): ModelInfo {
|
||||
// Stub - would copy file and extract metadata
|
||||
return ModelInfo.getSampleModels().first()
|
||||
// Obtain the local model's file via provided URI
|
||||
val fileName = getFileNameFromUri(uri)
|
||||
val modelFile = File(modelsDir, fileName)
|
||||
|
||||
// Copy file to app's internal storage
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
FileOutputStream(modelFile).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
} ?: throw IOException("Failed to open input stream")
|
||||
|
||||
// Extract model parameters from filename
|
||||
val modelType = extractModelTypeFromFilename(fileName) ?: "unknown"
|
||||
val parameters = extractParametersFromFilename(fileName) ?: "unknown"
|
||||
val quantization = extractQuantizationFromFilename(fileName) ?: "unknown"
|
||||
|
||||
// Create model entity and save via DAO
|
||||
val modelEntity = ModelEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = fileName.substringBeforeLast('.'),
|
||||
path = modelFile.absolutePath,
|
||||
sizeInBytes = modelFile.length(),
|
||||
parameters = parameters,
|
||||
quantization = quantization,
|
||||
type = modelType,
|
||||
contextLength = DEFAULT_CONTEXT_SIZE,
|
||||
lastUsed = null,
|
||||
dateAdded = System.currentTimeMillis()
|
||||
)
|
||||
modelDao.insertModel(modelEntity)
|
||||
return modelEntity.toModelInfo()
|
||||
}
|
||||
|
||||
override suspend fun deleteModel(modelId: String) {
|
||||
// Stub - would delete from filesystem and database
|
||||
modelDao.getModelById(modelId)?.let { model ->
|
||||
File(model.path).let {
|
||||
if (it.exists()) { it.delete() }
|
||||
}
|
||||
modelDao.deleteModel(model)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteModels(modelIds: Collection<String>) {
|
||||
// Stub - would delete from filesystem and database
|
||||
modelDao.getModelsByIds(modelIds).let { models ->
|
||||
models.forEach { model ->
|
||||
File(model.path).let {
|
||||
if (it.exists()) { it.delete() }
|
||||
}
|
||||
}
|
||||
modelDao.deleteModels(models)
|
||||
}
|
||||
}
|
||||
|
||||
val modelsSizeBytes: Long
|
||||
get() = modelsDir.listFiles()?.fold(0L) { totalSize, file ->
|
||||
totalSize + if (file.isFile) file.length() else 0
|
||||
} ?: 0L
|
||||
|
||||
val availableSpaceBytes: Long
|
||||
get() = StatFs(context.filesDir.path).availableBytes
|
||||
|
||||
val totalSpaceBytes: Long
|
||||
get() = StatFs(context.filesDir.path).totalBytes
|
||||
|
||||
private fun getFileNameFromUri(uri: Uri): String =
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { nameIndex ->
|
||||
if (nameIndex != -1) cursor.getString(nameIndex) else null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} ?: uri.lastPathSegment ?: "unknown_model.gguf"
|
||||
|
||||
/**
|
||||
* Try to extract parameters by looking for patterns like 7B, 13B, etc.
|
||||
*
|
||||
* TODO-han.yin: Enhance and move into a utility object for unit testing
|
||||
*/
|
||||
private fun extractParametersFromFilename(filename: String): String? =
|
||||
Regex("([0-9]+(\\.[0-9]+)?)[bB]").find(filename)?.value?.uppercase()
|
||||
|
||||
/**
|
||||
* Try to extract quantization by looking for patterns like Q4_0, Q5_K_M, etc.
|
||||
*/
|
||||
private fun extractQuantizationFromFilename(filename: String) =
|
||||
listOf(
|
||||
Regex("[qQ][0-9]_[0-9]"),
|
||||
Regex("[qQ][0-9]_[kK]_[mM]"),
|
||||
Regex("[qQ][0-9]_[kK]"),
|
||||
Regex("[qQ][0-9][fF](16|32)")
|
||||
).firstNotNullOfOrNull {
|
||||
it.find(filename)?.value?.uppercase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract model type (Llama, Mistral, etc.)
|
||||
*
|
||||
* TODO-han.yin: Replace with GGUF parsing, also to be moved into the util object
|
||||
*/
|
||||
private fun extractModelTypeFromFilename(filename: String): String? {
|
||||
val lowerFilename = filename.lowercase()
|
||||
return listOf("llama", "mistral", "phi", "qwen", "falcon", "mpt")
|
||||
.firstNotNullOfOrNull { type ->
|
||||
if (lowerFilename.contains(type)) {
|
||||
type.replaceFirstChar {
|
||||
if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString()
|
||||
}
|
||||
} else { null }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val INTERNAL_STORAGE_PATH = "models"
|
||||
private const val BYTES_IN_GB = 1024f * 1024f * 1024f
|
||||
|
||||
private const val STORAGE_METRICS_UPDATE_INTERVAL = 5_000L
|
||||
private const val DEFAULT_CONTEXT_SIZE = 8192
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
data class StorageMetrics(
|
||||
val usedGB: Float,
|
||||
val totalGB: Float
|
||||
) {
|
||||
val percentUsed: Float
|
||||
get() = if (totalGB > 0) usedGB / totalGB else 0f
|
||||
|
||||
val freeGB: Float
|
||||
get() = totalGB - usedGB
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,16 +63,16 @@ fun ModelsManagementScreen(
|
|||
onBackPressed: () -> Unit,
|
||||
viewModel: ModelsManagementViewModel = hiltViewModel()
|
||||
) {
|
||||
// For demo purposes, we'll use sample models
|
||||
val models by viewModel.availableModels.collectAsState()
|
||||
val sortedModels by viewModel.sortedModels.collectAsState()
|
||||
val storageMetrics by viewModel.storageMetrics.collectAsState()
|
||||
|
||||
// UI states
|
||||
var isMultiSelectionMode by remember { mutableStateOf(false) }
|
||||
val selectedModels = remember { mutableStateMapOf<String, ModelInfo>() }
|
||||
// UI: menu states
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
var showAddModelMenu by remember { mutableStateOf(false) }
|
||||
|
||||
// UI: multi-selection states
|
||||
var isMultiSelectionMode by remember { mutableStateOf(false) }
|
||||
val selectedModels = remember { mutableStateMapOf<String, ModelInfo>() }
|
||||
val exitSelectionMode = {
|
||||
isMultiSelectionMode = false
|
||||
selectedModels.clear()
|
||||
|
|
@ -80,8 +80,8 @@ fun ModelsManagementScreen(
|
|||
|
||||
StorageAppScaffold(
|
||||
title = "Models Management",
|
||||
storageUsed = storageMetrics.usedGB,
|
||||
storageTotal = storageMetrics.totalGB,
|
||||
storageUsed = storageMetrics?.usedGB ?: 0f,
|
||||
storageTotal = storageMetrics?.totalGB ?: 0f,
|
||||
onNavigateBack = onBackPressed,
|
||||
bottomBar = {
|
||||
BottomAppBar(
|
||||
|
|
@ -90,7 +90,7 @@ fun ModelsManagementScreen(
|
|||
// Multi-selection mode actions
|
||||
IconButton(onClick = {
|
||||
// Select all
|
||||
selectedModels.putAll(models.map { it.id to it })
|
||||
selectedModels.putAll(sortedModels.map { it.id to it })
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
|
|
@ -226,7 +226,8 @@ fun ModelsManagementScreen(
|
|||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.importLocalModel()
|
||||
// TODO-han.yin: uncomment once file picker done
|
||||
// viewModel.importLocalModel()
|
||||
showAddModelMenu = false
|
||||
}
|
||||
)
|
||||
|
|
@ -252,7 +253,7 @@ fun ModelsManagementScreen(
|
|||
) { paddingValues ->
|
||||
// Main content
|
||||
ModelList(
|
||||
models = models,
|
||||
models = sortedModels,
|
||||
isMultiSelectionMode = isMultiSelectionMode,
|
||||
selectedModels = selectedModels,
|
||||
onModelClick = { modelId ->
|
||||
|
|
@ -261,7 +262,7 @@ fun ModelsManagementScreen(
|
|||
if (selectedModels.contains(modelId)) {
|
||||
selectedModels.remove(modelId)
|
||||
} else {
|
||||
selectedModels.put(modelId, models.first { it.id == modelId } )
|
||||
selectedModels.put(modelId, sortedModels.first { it.id == modelId } )
|
||||
}
|
||||
} else {
|
||||
// View model details
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.example.llama.revamp.viewmodel
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.llama.revamp.data.model.ModelInfo
|
||||
|
|
@ -7,8 +9,11 @@ import com.example.llama.revamp.data.repository.ModelRepository
|
|||
import com.example.llama.revamp.data.repository.StorageMetrics
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -17,52 +22,41 @@ class ModelsManagementViewModel @Inject constructor(
|
|||
private val modelRepository: ModelRepository
|
||||
) : ViewModel() {
|
||||
|
||||
// Sort order state
|
||||
val storageMetrics: StateFlow<StorageMetrics?> = modelRepository.getStorageMetrics()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
private val _availableModels: StateFlow<List<ModelInfo>> = modelRepository.getModels()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
private val _sortOrder = MutableStateFlow(ModelSortOrder.NAME_ASC)
|
||||
val sortOrder: StateFlow<ModelSortOrder> = _sortOrder.asStateFlow()
|
||||
|
||||
// Available models
|
||||
private val _availableModels = MutableStateFlow<List<ModelInfo>>(emptyList())
|
||||
val availableModels: StateFlow<List<ModelInfo>> = _availableModels.asStateFlow()
|
||||
|
||||
// Storage metrics
|
||||
private val _storageMetrics = MutableStateFlow(StorageMetrics(0f, 0f))
|
||||
val storageMetrics: StateFlow<StorageMetrics> = _storageMetrics.asStateFlow()
|
||||
private val _sortedModels = MutableStateFlow<List<ModelInfo>>(emptyList())
|
||||
val sortedModels: StateFlow<List<ModelInfo>> = _sortedModels.asStateFlow()
|
||||
|
||||
init {
|
||||
// Initial data load
|
||||
viewModelScope.launch {
|
||||
loadModels()
|
||||
loadStorageMetrics()
|
||||
}
|
||||
|
||||
// Observe sort order changes and apply sorting
|
||||
viewModelScope.launch {
|
||||
sortOrder.collect { order -> sortModels(order) }
|
||||
combine(_availableModels, _sortOrder, ::sortModels)
|
||||
.collect { _sortedModels.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadModels() {
|
||||
// TODO-han.yin: Stub for now. Would load from the repository
|
||||
_availableModels.value = ModelInfo.getSampleModels()
|
||||
sortModels(_sortOrder.value)
|
||||
}
|
||||
|
||||
private fun loadStorageMetrics() {
|
||||
// TODO-han.yin: Stub for now. Would load from storage
|
||||
_storageMetrics.value = StorageMetrics(14.6f, 32.0f)
|
||||
}
|
||||
|
||||
private fun sortModels(order: ModelSortOrder) {
|
||||
val sorted = when (order) {
|
||||
ModelSortOrder.NAME_ASC -> _availableModels.value.sortedBy { it.name }
|
||||
ModelSortOrder.NAME_DESC -> _availableModels.value.sortedByDescending { it.name }
|
||||
ModelSortOrder.SIZE_ASC -> _availableModels.value.sortedBy { it.sizeInBytes }
|
||||
ModelSortOrder.SIZE_DESC -> _availableModels.value.sortedByDescending { it.sizeInBytes }
|
||||
ModelSortOrder.LAST_USED -> _availableModels.value.sortedByDescending { it.lastUsed ?: 0 }
|
||||
private fun sortModels(models: List<ModelInfo>, order: ModelSortOrder) =
|
||||
when (order) {
|
||||
ModelSortOrder.NAME_ASC -> models.sortedBy { it.name }
|
||||
ModelSortOrder.NAME_DESC -> models.sortedByDescending { it.name }
|
||||
ModelSortOrder.SIZE_ASC -> models.sortedBy { it.sizeInBytes }
|
||||
ModelSortOrder.SIZE_DESC -> models.sortedByDescending { it.sizeInBytes }
|
||||
ModelSortOrder.LAST_USED -> models.sortedByDescending { it.lastUsed ?: 0 }
|
||||
}
|
||||
_availableModels.value = sorted
|
||||
}
|
||||
|
||||
fun setSortOrder(order: ModelSortOrder) {
|
||||
_sortOrder.value = order
|
||||
|
|
@ -72,43 +66,34 @@ class ModelsManagementViewModel @Inject constructor(
|
|||
// TODO-han.yin: Stub for now. Would navigate to model details screen or show dialog
|
||||
}
|
||||
|
||||
fun deleteModel(modelId: String) {
|
||||
// Remove model from list
|
||||
_availableModels.value = _availableModels.value.filter { it.id != modelId }
|
||||
|
||||
fun deleteModel(modelId: String) =
|
||||
viewModelScope.launch {
|
||||
// TODO-han.yin: Stub for now this would delete from storage
|
||||
modelRepository.deleteModel(modelId)
|
||||
updateStorageMetrics()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteModels(models: Map<String, ModelInfo>) {
|
||||
val modelIds = models.keys
|
||||
_availableModels.value = _availableModels.value.filter { !modelIds.contains(it.id) }
|
||||
|
||||
fun deleteModels(models: Map<String, ModelInfo>) =
|
||||
viewModelScope.launch {
|
||||
modelRepository.deleteModels(modelIds)
|
||||
updateStorageMetrics()
|
||||
modelRepository.deleteModels(models.keys)
|
||||
}
|
||||
}
|
||||
|
||||
fun importLocalModel() {
|
||||
// TODO-han.yin: Stub for now. Would open file picker and import model
|
||||
}
|
||||
fun importLocalModel(uri: Uri) =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
modelRepository.importModel(uri)
|
||||
} catch (e: Exception) {
|
||||
// TODO-han.yin: add UI to prompt user about import failure!
|
||||
Log.e(TAG, "Failed to import model from: $uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun importFromHuggingFace() {
|
||||
// TODO-han.yin: Stub for now. Would need to investigate HuggingFace APIs
|
||||
}
|
||||
|
||||
private fun updateStorageMetrics() {
|
||||
// Recalculate storage metrics after model changes
|
||||
// TODO-han.yin: Stub for now. Would query actual storage
|
||||
val totalSize = _availableModels.value.sumOf { it.sizeInBytes }
|
||||
_storageMetrics.value = StorageMetrics(
|
||||
(totalSize / 1_000_000_000.0).toFloat(),
|
||||
32.0f
|
||||
)
|
||||
companion object {
|
||||
private val TAG = ModelsManagementViewModel::class.java.simpleName
|
||||
|
||||
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue