From 2614f9122630175004720c44ec6b97329bd457ab Mon Sep 17 00:00:00 2001 From: Han Yin Date: Mon, 14 Apr 2025 23:36:12 -0700 Subject: [PATCH] UI: replace model selection screen's data stubbing; add empty view --- .../revamp/data/repository/ModelRepository.kt | 12 +- .../revamp/ui/screens/ModelSelectionScreen.kt | 103 +++++++++++++----- .../llama/revamp/viewmodel/MainViewModel.kt | 42 ++++--- 3 files changed, 111 insertions(+), 46 deletions(-) diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt index e28660495d..0767368bab 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt @@ -51,6 +51,8 @@ interface ModelRepository { progressTracker: ImportProgressTracker? = null ): ModelInfo + suspend fun updateModelLastUsed(modelId: String) + suspend fun deleteModel(modelId: String) suspend fun deleteModels(modelIds: List) @@ -230,16 +232,20 @@ class ModelRepositoryImpl @Inject constructor( input.close() } - override suspend fun deleteModel(modelId: String) { + override suspend fun updateModelLastUsed(modelId: String) = withContext(Dispatchers.IO) { + modelDao.updateLastUsed(modelId, System.currentTimeMillis()) + } + + override suspend fun deleteModel(modelId: String) = withContext(Dispatchers.IO) { modelDao.getModelById(modelId)?.let { model -> File(model.path).let { if (it.exists()) { it.delete() } } modelDao.deleteModel(model) - } + } ?: Unit } - override suspend fun deleteModels(modelIds: List) { + override suspend fun deleteModels(modelIds: List) = withContext(Dispatchers.IO) { modelDao.getModelsByIds(modelIds).let { models -> models.forEach { model -> File(model.path).let { diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt index d94c7eb880..fe56d576bd 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt @@ -1,24 +1,36 @@ package com.example.llama.revamp.ui.screens +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.example.llama.revamp.data.model.ModelInfo import com.example.llama.revamp.ui.components.ModelCard import com.example.llama.revamp.ui.components.ModelCardActions import com.example.llama.revamp.ui.components.PerformanceAppScaffold +import com.example.llama.revamp.viewmodel.MainViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -26,9 +38,9 @@ fun ModelSelectionScreen( onModelSelected: (ModelInfo) -> Unit, onManageModelsClicked: () -> Unit, onMenuClicked: () -> Unit, + viewModel: MainViewModel = hiltViewModel(), ) { - // For demo purposes, we'll use sample models - val models = remember { ModelInfo.getSampleModels() } + val models by viewModel.availableModels.collectAsState() PerformanceAppScaffold( title = "Models", @@ -41,29 +53,70 @@ fun ModelSelectionScreen( .padding(paddingValues) .padding(horizontal = 16.dp) ) { - TextButton( - onClick = onManageModelsClicked, - modifier = Modifier - .align(Alignment.End) - .padding(top = 8.dp, bottom = 8.dp) - ) { - Text("Manage Models") - } - - LazyColumn { - items(models) { model -> - ModelCard( - model = model, - onClick = { onModelSelected(model) }, - modifier = Modifier.padding(vertical = 4.dp), - isSelected = null, // Not in selection mode - actionButton = { - ModelCardActions.PlayButton(onClick = { onModelSelected(model) }) - } - ) - Spacer(modifier = Modifier.height(8.dp)) + if (models.isEmpty()) { + EmptyModelsView(onManageModelsClicked) + } else { + LazyColumn { + items(models) { model -> + ModelCard( + model = model, + onClick = { onModelSelected(model) }, + modifier = Modifier.padding(vertical = 4.dp), + isSelected = null, // Not in selection mode + actionButton = { + ModelCardActions.PlayButton(onClick = { onModelSelected(model) }) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + } } } } } } + +@Composable +private fun EmptyModelsView(onManageModelsClicked: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "No Models Available", + style = MaterialTheme.typography.headlineSmall + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Add models to get started with local LLM inference", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button(onClick = onManageModelsClicked) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Add Models") + } + } +} diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/MainViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/MainViewModel.kt index f6c854b088..fee0d6250a 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/MainViewModel.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/MainViewModel.kt @@ -1,17 +1,19 @@ package com.example.llama.revamp.viewmodel import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.example.llama.revamp.data.model.ModelInfo +import com.example.llama.revamp.data.repository.ModelRepository import com.example.llama.revamp.engine.InferenceEngine import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date @@ -23,7 +25,8 @@ import javax.inject.Inject */ @HiltViewModel class MainViewModel @Inject constructor ( - private val inferenceEngine: InferenceEngine + private val inferenceEngine: InferenceEngine, + private val modelRepository: ModelRepository, ) : ViewModel() { // Expose the engine state @@ -32,6 +35,14 @@ class MainViewModel @Inject constructor ( // Benchmark results val benchmarkResults: StateFlow = inferenceEngine.benchmarkResults + // Available models for selection + val availableModels: StateFlow> = modelRepository.getModels() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS), + initialValue = emptyList() + ) + // Selected model information private val _selectedModel = MutableStateFlow(null) val selectedModel: StateFlow = _selectedModel.asStateFlow() @@ -52,6 +63,10 @@ class MainViewModel @Inject constructor ( */ fun selectModel(modelInfo: ModelInfo) { _selectedModel.value = modelInfo + + viewModelScope.launch { + modelRepository.updateModelLastUsed(modelInfo.id) + } } /** @@ -238,16 +253,14 @@ class MainViewModel @Inject constructor ( } /** - * Unloads the currently loaded model. + * Unloads the currently loaded model after cleanup chores: + * - Cancel any ongoing token collection + * - Clear messages */ suspend fun unloadModel() { - // Cancel any ongoing token collection tokenCollectionJob?.cancel() - - // Clear messages _messages.value = emptyList() - // Unload model inferenceEngine.unloadModel() } @@ -259,17 +272,10 @@ class MainViewModel @Inject constructor ( super.onCleared() } - /** - * Factory for creating MainViewModel instances. - */ - class Factory(private val inferenceEngine: InferenceEngine) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(MainViewModel::class.java)) { - return MainViewModel(inferenceEngine) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } + companion object { + private val TAG = MainViewModel::class.java.simpleName + + private const val SUBSCRIPTION_TIMEOUT_MS = 5000L } }