From a1bad6b66bedd97a3f761b3b8c34c38b7a723825 Mon Sep 17 00:00:00 2001 From: Hansong Zhang Date: Wed, 28 Jan 2026 15:44:21 -0800 Subject: [PATCH 01/14] LlamaDemo Add LoRA multi-model support with in-chat model switching - Add ModelConfiguration data class for individual PTE model configs - Add LoRA mode toggle in settings to enable multi-model selection - Add multi-model management UI with add/remove model flows - Add in-chat model switcher button and dialog for switching between loaded models - Support caching loaded LlmModule instances for instant model switching - Add shared data path (PTD) support for LoRA adapters - Maintain backward compatibility with legacy single-model mode --- .../executorchllamademo/ModelConfiguration.kt | 64 +++ .../executorchllamademo/ModuleSettings.kt | 116 ++++- .../ui/components/ModelListItem.kt | 114 +++++ .../ui/screens/ChatScreen.kt | 67 +++ .../ui/screens/ModelSettingsScreen.kt | 426 ++++++++++++++++-- .../ui/viewmodel/ChatViewModel.kt | 206 ++++++++- .../ui/viewmodel/ModelSettingsViewModel.kt | 198 +++++++- 7 files changed, 1129 insertions(+), 62 deletions(-) create mode 100644 llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModelConfiguration.kt create mode 100644 llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/components/ModelListItem.kt diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModelConfiguration.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModelConfiguration.kt new file mode 100644 index 00000000..1f80bd65 --- /dev/null +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModelConfiguration.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.example.executorchllamademo + +/** + * Represents a single PTE model configuration. + * Multiple ModelConfigurations can share the same PTD (data path) file for LoRA support. + */ +data class ModelConfiguration( + val id: String = "", + val modelFilePath: String = "", + val tokenizerFilePath: String = "", + val modelType: ModelType = ModelType.LLAMA_3, + val backendType: BackendType = BackendType.XNNPACK, + val temperature: Double = ModuleSettings.DEFAULT_TEMPERATURE, + val displayName: String = "" +) { + companion object { + fun create( + modelFilePath: String, + tokenizerFilePath: String, + modelType: ModelType, + backendType: BackendType, + temperature: Double + ): ModelConfiguration { + return ModelConfiguration( + id = generateId(modelFilePath), + modelFilePath = modelFilePath, + tokenizerFilePath = tokenizerFilePath, + modelType = modelType, + backendType = backendType, + temperature = temperature, + displayName = extractDisplayName(modelFilePath) + ) + } + + private fun generateId(modelFilePath: String): String { + return modelFilePath.hashCode().toString() + } + + private fun extractDisplayName(filePath: String): String { + if (filePath.isEmpty()) return "" + return filePath.substringAfterLast('/') + } + } + + fun isValid(): Boolean { + return modelFilePath.isNotEmpty() && tokenizerFilePath.isNotEmpty() + } + + fun withModelFilePath(path: String): ModelConfiguration { + return copy( + modelFilePath = path, + id = generateId(path), + displayName = extractDisplayName(path) + ) + } +} diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt index 34486ed7..be8e5004 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt @@ -10,8 +10,10 @@ package com.example.executorchllamademo /** * Holds module-specific settings for the current model/tokenizer configuration. + * Supports both legacy single-model and multi-model configurations for LoRA support. */ data class ModuleSettings( + // Legacy single-model fields (kept for backward compatibility) val modelFilePath: String = "", val tokenizerFilePath: String = "", val dataPath: String = "", @@ -21,22 +23,130 @@ data class ModuleSettings( val modelType: ModelType = DEFAULT_MODEL, val backendType: BackendType = DEFAULT_BACKEND, val isClearChatHistory: Boolean = false, - val isLoadModel: Boolean = false + val isLoadModel: Boolean = false, + + // LoRA mode toggle - when enabled, allows multiple model selection + val isLoraMode: Boolean = false, + + // Multi-model support fields (used when isLoraMode is true) + val models: List = emptyList(), + val activeModelId: String = "", + val sharedDataPath: String = "" ) { + /** + * Gets the effective model type, considering multi-model configuration. + */ + fun getEffectiveModelType(): ModelType { + val activeModel = getActiveModel() + return activeModel?.modelType ?: modelType + } + fun getFormattedSystemPrompt(): String { - return PromptFormat.getSystemPromptTemplate(modelType) + return PromptFormat.getSystemPromptTemplate(getEffectiveModelType()) .replace(PromptFormat.SYSTEM_PLACEHOLDER, systemPrompt) } fun getFormattedUserPrompt(prompt: String, thinkingMode: Boolean): String { + val effectiveType = getEffectiveModelType() return userPrompt .replace(PromptFormat.USER_PLACEHOLDER, prompt) .replace( PromptFormat.THINKING_MODE_PLACEHOLDER, - PromptFormat.getThinkingModeToken(modelType, thinkingMode) + PromptFormat.getThinkingModeToken(effectiveType, thinkingMode) ) } + /** + * Gets the active model configuration if using multi-model mode. + */ + fun getActiveModel(): ModelConfiguration? { + if (models.isEmpty() || activeModelId.isEmpty()) return null + return models.find { it.id == activeModelId } + } + + /** + * Gets a model configuration by ID. + */ + fun getModelById(modelId: String): ModelConfiguration? { + return models.find { it.id == modelId } + } + + /** + * Gets the effective shared data path (falls back to legacy dataPath). + */ + fun getEffectiveDataPath(): String { + return sharedDataPath.ifEmpty { dataPath } + } + + /** + * Checks if there are multiple models configured. + */ + fun hasMultipleModels(): Boolean = models.size > 1 + + /** + * Checks if any models are configured. + */ + fun hasModels(): Boolean = models.isNotEmpty() + + /** + * Adds a model to the list. If a model with the same ID exists, it's replaced. + * If this is the first model, it becomes the active model. + */ + fun addModel(model: ModelConfiguration): ModuleSettings { + val existingIndex = models.indexOfFirst { it.id == model.id } + val newModels = if (existingIndex >= 0) { + models.toMutableList().apply { this[existingIndex] = model } + } else { + models + model + } + val newActiveId = if (models.isEmpty()) model.id else activeModelId + return copy(models = newModels, activeModelId = newActiveId) + } + + /** + * Removes a model by ID. If the active model is removed, selects another. + */ + fun removeModel(modelId: String): ModuleSettings { + val newModels = models.filter { it.id != modelId } + val newActiveId = if (activeModelId == modelId) { + newModels.firstOrNull()?.id ?: "" + } else { + activeModelId + } + return copy(models = newModels, activeModelId = newActiveId) + } + + /** + * Sets the active model by ID. + */ + fun setActiveModel(modelId: String): ModuleSettings { + return copy(activeModelId = modelId) + } + + /** + * Migrates legacy single-model settings to multi-model format. + */ + fun migrateToMultiModel(): ModuleSettings { + if (models.isNotEmpty()) return this + + // Only migrate if there's a valid legacy model configuration + if (modelFilePath.isEmpty() || tokenizerFilePath.isEmpty()) return this + + val legacyModel = ModelConfiguration.create( + modelFilePath = modelFilePath, + tokenizerFilePath = tokenizerFilePath, + modelType = modelType, + backendType = backendType, + temperature = temperature + ) + + return copy( + models = listOf(legacyModel), + activeModelId = legacyModel.id, + sharedDataPath = dataPath + ) + } + companion object { const val DEFAULT_TEMPERATURE = 0.0 val DEFAULT_MODEL = ModelType.LLAMA_3 diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/components/ModelListItem.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/components/ModelListItem.kt new file mode 100644 index 00000000..bc8baaf9 --- /dev/null +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/components/ModelListItem.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.example.executorchllamademo.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.executorchllamademo.ModelConfiguration +import com.example.executorchllamademo.ui.theme.LocalAppColors + +/** + * A composable that displays a single model configuration in a list. + * Shows the model name, type, backend, and tokenizer, with a radio button + * for selection and a remove button. + */ +@Composable +fun ModelListItem( + model: ModelConfiguration, + isActive: Boolean, + onSelect: () -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier +) { + val appColors = LocalAppColors.current + + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onSelect) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isActive, + onClick = onSelect, + colors = RadioButtonDefaults.colors( + selectedColor = appColors.settingsText, + unselectedColor = appColors.settingsSecondaryText + ) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + // Model name (display name from file path) + Text( + text = model.displayName.ifEmpty { "Unknown Model" }, + color = appColors.settingsText, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Model type and backend + Text( + text = "${model.modelType} | ${model.backendType}", + color = appColors.settingsSecondaryText, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Tokenizer name + val tokenizerName = model.tokenizerFilePath.substringAfterLast('/').ifEmpty { "No tokenizer" } + Text( + text = tokenizerName, + color = appColors.settingsSecondaryText.copy(alpha = 0.7f), + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + IconButton( + onClick = onRemove, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Remove model", + tint = Color(0xFFFF6666) + ) + } + } +} diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ChatScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ChatScreen.kt index abb7ada2..ad4f1874 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ChatScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ChatScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding @@ -23,10 +24,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -67,6 +70,7 @@ fun ChatScreen( onAudioFileSelected: (String) -> Unit ) { var showAudioDialog by remember { mutableStateOf(false) } + var showModelSwitcherDialog by remember { mutableStateOf(false) } val listState = rememberLazyListState() val appColors = LocalAppColors.current val focusManager = LocalFocusManager.current @@ -132,6 +136,16 @@ fun ChatScreen( color = appColors.textOnNavBar, fontSize = 14.sp ) + // Model switcher button - only visible in LoRA mode + if (viewModel.isLoraMode) { + IconButton(onClick = { showModelSwitcherDialog = true }) { + Icon( + imageVector = Icons.Filled.SwapHoriz, + contentDescription = "Switch Model", + tint = appColors.textOnNavBar + ) + } + } IconButton(onClick = onLogsClick) { Icon( imageVector = Icons.Filled.Article, @@ -252,4 +266,57 @@ fun ChatScreen( } ) } + + // Model switcher dialog (LoRA mode) + if (showModelSwitcherDialog && viewModel.isLoraMode) { + AlertDialog( + onDismissRequest = { showModelSwitcherDialog = false }, + title = { Text("Switch Model") }, + text = { + Column { + if (viewModel.availableModels.isEmpty()) { + Text("No models configured. Add models in Settings.") + } else { + viewModel.availableModels.forEach { model -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + viewModel.switchToModel(model.id) + showModelSwitcherDialog = false + } + .padding(vertical = 8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + RadioButton( + selected = model.id == viewModel.activeModelId, + onClick = { + viewModel.switchToModel(model.id) + showModelSwitcherDialog = false + } + ) + Column(modifier = Modifier.padding(start = 8.dp)) { + Text( + text = model.displayName.ifEmpty { "Unknown" }, + fontWeight = FontWeight.Bold + ) + Text( + text = "${model.modelType}", + fontSize = 12.sp, + color = appColors.settingsSecondaryText + ) + } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { showModelSwitcherDialog = false }) { + Text("Cancel") + } + } + ) + } } diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt index 0363a37b..3de4b7ae 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt @@ -64,6 +64,8 @@ import com.example.executorchllamademo.AppearanceMode import com.example.executorchllamademo.BackendType import com.example.executorchllamademo.ModelType import com.example.executorchllamademo.PromptFormat +import com.example.executorchllamademo.ModelConfiguration +import com.example.executorchllamademo.ui.components.ModelListItem import com.example.executorchllamademo.ui.components.SettingsRow import com.example.executorchllamademo.ui.theme.BtnDisabled import com.example.executorchllamademo.ui.theme.BtnEnabled @@ -129,71 +131,177 @@ fun ModelSettingsScreen( onClick = { viewModel.showBackendDialog = true } ) - // Only show these for non-MediaTek backends - if (!viewModel.isMediaTekMode()) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) - // Model selector - SettingsRow( - label = "Model", - value = viewModel.getFilenameFromPath(viewModel.moduleSettings.modelFilePath) - .ifEmpty { "no model selected" }, - onClick = { - viewModel.refreshFileLists() - viewModel.showModelDialog = true - } + // ========== LoRA Mode Toggle ========== + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "LoRA Mode", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = appColors.settingsText + ) + Text( + text = "Enable for multiple model selection", + fontSize = 12.sp, + color = appColors.settingsSecondaryText + ) + } + androidx.compose.material3.Switch( + checked = viewModel.moduleSettings.isLoraMode, + onCheckedChange = { viewModel.toggleLoraMode(it) } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // ========== Conditional UI based on LoRA mode ========== + if (viewModel.moduleSettings.isLoraMode) { + // LoRA Mode: Multi-model selection + Text( + text = "Models", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = appColors.settingsText ) Spacer(modifier = Modifier.height(8.dp)) - // Tokenizer selector - SettingsRow( - label = "Tokenizer", - value = viewModel.getFilenameFromPath(viewModel.moduleSettings.tokenizerFilePath) - .ifEmpty { "no tokenizer selected" }, - onClick = { - viewModel.refreshFileLists() - viewModel.showTokenizerDialog = true + // Model list + if (viewModel.moduleSettings.hasModels()) { + viewModel.moduleSettings.models.forEach { model -> + ModelListItem( + model = model, + isActive = model.id == viewModel.moduleSettings.activeModelId, + onSelect = { viewModel.selectActiveModel(model.id) }, + onRemove = { viewModel.initiateRemoveModel(model.id) } + ) } - ) + } else { + Text( + text = "No models configured", + fontSize = 14.sp, + color = appColors.settingsSecondaryText, + modifier = Modifier.padding(vertical = 8.dp) + ) + } Spacer(modifier = Modifier.height(8.dp)) - // Data path selector + // Add Model button + Button( + onClick = { viewModel.startAddModel() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = BtnEnabled + ), + shape = RoundedCornerShape(8.dp) + ) { + Text("+ Add Model", color = Color.White) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Shared Data Path (LoRA) selector SettingsRow( - label = "Data Path", - value = viewModel.getFilenameFromPath(viewModel.moduleSettings.dataPath) + label = "Shared Data Path (LoRA)", + value = viewModel.getFilenameFromPath(viewModel.moduleSettings.getEffectiveDataPath()) .ifEmpty { "no data path selected" }, onClick = { viewModel.refreshFileLists() viewModel.showDataPathDialog = true } ) - } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) - // Model type selector - SettingsRow( - label = "Model Type", - value = viewModel.moduleSettings.modelType.toString(), - onClick = { viewModel.showModelTypeDialog = true } - ) + // Load All Models button + Button( + onClick = { viewModel.initiateLoadModels() }, + enabled = viewModel.isLoadModelEnabled(), + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = BtnEnabled, + disabledContainerColor = BtnDisabled + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = if (viewModel.moduleSettings.hasMultipleModels()) "Load All Models" else "Load Model", + color = Color.White + ) + } + } else { + // Normal Mode: Single-model selection (original UI) + if (!viewModel.isMediaTekMode()) { + // Model selector + SettingsRow( + label = "Model", + value = viewModel.getFilenameFromPath(viewModel.moduleSettings.modelFilePath) + .ifEmpty { "no model selected" }, + onClick = { + viewModel.refreshFileLists() + viewModel.showModelDialog = true + } + ) - Spacer(modifier = Modifier.height(12.dp)) - - // Load Model button - Button( - onClick = { viewModel.showLoadModelDialog = true }, - enabled = viewModel.isLoadModelEnabled(), - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = BtnEnabled, - disabledContainerColor = BtnDisabled - ), - shape = RoundedCornerShape(8.dp) - ) { - Text("Load Model", color = Color.White) + Spacer(modifier = Modifier.height(8.dp)) + + // Tokenizer selector + SettingsRow( + label = "Tokenizer", + value = viewModel.getFilenameFromPath(viewModel.moduleSettings.tokenizerFilePath) + .ifEmpty { "no tokenizer selected" }, + onClick = { + viewModel.refreshFileLists() + viewModel.showTokenizerDialog = true + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Data path selector + SettingsRow( + label = "Data Path", + value = viewModel.getFilenameFromPath(viewModel.moduleSettings.dataPath) + .ifEmpty { "no data path selected" }, + onClick = { + viewModel.refreshFileLists() + viewModel.showDataPathDialog = true + } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Model type selector + SettingsRow( + label = "Model Type", + value = viewModel.moduleSettings.modelType.toString(), + onClick = { viewModel.showModelTypeDialog = true } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Load Model button + Button( + onClick = { viewModel.showLoadModelDialog = true }, + enabled = viewModel.isLoadModelEnabled(), + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = BtnEnabled, + disabledContainerColor = BtnDisabled + ), + shape = RoundedCornerShape(8.dp) + ) { + Text("Load Model", color = Color.White) + } } if (!viewModel.isMediaTekMode()) { @@ -337,6 +445,9 @@ fun ModelSettingsScreen( ResetSystemPromptDialog(viewModel) ResetUserPromptDialog(viewModel) InvalidPromptDialog(viewModel) + AddModelDialog(viewModel) + RemoveModelDialog(viewModel) + MemoryWarningDialog(viewModel) } @Composable @@ -706,3 +817,226 @@ private fun SingleChoiceDialog( } ) } + +@Composable +private fun AddModelDialog(viewModel: ModelSettingsViewModel) { + if (!viewModel.showAddModelDialog) return + + when (viewModel.addModelStep) { + 1 -> { + // Step 1: Select model file + if (viewModel.modelFiles.isEmpty()) { + AlertDialog( + onDismissRequest = { viewModel.cancelAddModel() }, + title = { Text("Step 1: Select Model (.pte)") }, + text = { + Text("No .pte files found in /data/local/tmp/llama/\n\nPlease push model files using:\nadb push .pte /data/local/tmp/llama/") + }, + confirmButton = { + TextButton(onClick = { viewModel.cancelAddModel() }) { + Text("OK") + } + } + ) + } else { + SingleChoiceDialogWithPreselection( + title = "Step 1: Select Model (.pte)", + options = viewModel.modelFiles.toList(), + selectedOption = null, + onSelect = { selected -> + viewModel.selectTempModel(selected) + }, + onDismiss = { viewModel.cancelAddModel() }, + dismissButtonText = "Cancel" + ) + } + } + 2 -> { + // Step 2: Select tokenizer file + if (viewModel.tokenizerFiles.isEmpty()) { + AlertDialog( + onDismissRequest = { viewModel.previousAddModelStep() }, + title = { Text("Step 2: Select Tokenizer") }, + text = { + Text("No tokenizer files found in /data/local/tmp/llama/\n\nPlease push tokenizer files using:\nadb push /data/local/tmp/llama/") + }, + confirmButton = { + TextButton(onClick = { viewModel.previousAddModelStep() }) { + Text("Back") + } + } + ) + } else { + SingleChoiceDialogWithPreselection( + title = "Step 2: Select Tokenizer", + options = viewModel.tokenizerFiles.toList(), + selectedOption = null, + onSelect = { selected -> + viewModel.selectTempTokenizer(selected) + }, + onDismiss = { viewModel.previousAddModelStep() }, + dismissButtonText = "Back" + ) + } + } + 3 -> { + // Step 3: Confirm model type + val modelTypes = ModelType.values().map { it.toString() } + val preSelectedIndex = ModelType.values().indexOfFirst { it == viewModel.tempModelType } + + AlertDialog( + onDismissRequest = { viewModel.previousAddModelStep() }, + title = { Text("Step 3: Confirm Model Type") }, + text = { + Column { + modelTypes.forEachIndexed { index, option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + viewModel.selectTempModelType(ModelType.valueOf(option)) + } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = index == preSelectedIndex || viewModel.tempModelType.toString() == option, + onClick = { + viewModel.selectTempModelType(ModelType.valueOf(option)) + } + ) + Text( + text = option, + modifier = Modifier.padding(start = 8.dp), + fontSize = 14.sp + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { viewModel.confirmAddModel() }) { + Text("Add Model") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.previousAddModelStep() }) { + Text("Back") + } + } + ) + } + } +} + +@Composable +private fun SingleChoiceDialogWithPreselection( + title: String, + options: List, + selectedOption: String?, + onSelect: (String) -> Unit, + onDismiss: () -> Unit, + dismissButtonText: String = "Cancel" +) { + var currentSelection by remember { mutableStateOf(selectedOption) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + options.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + currentSelection = option + onSelect(option) + } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = currentSelection == option, + onClick = null + ) + Text( + text = option.substringAfterLast('/'), + modifier = Modifier + .padding(start = 8.dp) + .weight(1f), + fontSize = 14.sp + ) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(dismissButtonText) + } + } + ) +} + +@Composable +private fun RemoveModelDialog(viewModel: ModelSettingsViewModel) { + if (!viewModel.showRemoveModelDialog) return + + val modelToRemove = viewModel.modelToRemove?.let { viewModel.moduleSettings.getModelById(it) } + val modelName = modelToRemove?.displayName ?: "this model" + + AlertDialog( + onDismissRequest = { viewModel.cancelRemoveModel() }, + icon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null + ) + }, + title = { Text("Remove Model") }, + text = { Text("Remove $modelName? Chat history will be preserved.") }, + confirmButton = { + TextButton(onClick = { viewModel.confirmRemoveModel() }) { + Text("Yes") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.cancelRemoveModel() }) { + Text("No") + } + } + ) +} + +@Composable +private fun MemoryWarningDialog(viewModel: ModelSettingsViewModel) { + if (!viewModel.showMemoryWarningDialog) return + + val modelCount = viewModel.moduleSettings.models.size + + AlertDialog( + onDismissRequest = { viewModel.showMemoryWarningDialog = false }, + icon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null + ) + }, + title = { Text("Memory Warning") }, + text = { + Text("Loading $modelCount models simultaneously will use significant RAM. Continue?") + }, + confirmButton = { + TextButton(onClick = { viewModel.proceedAfterMemoryWarning() }) { + Text("Continue") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.showMemoryWarningDialog = false }) { + Text("Cancel") + } + } + ) +} diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt index 5d895fff..60bdff08 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt @@ -24,6 +24,7 @@ import com.example.executorchllamademo.ETImage import com.example.executorchllamademo.ETLogging import com.example.executorchllamademo.Message import com.example.executorchllamademo.MessageType +import com.example.executorchllamademo.ModelConfiguration import com.example.executorchllamademo.ModelType import com.example.executorchllamademo.ModelUtils import com.example.executorchllamademo.PromptFormat @@ -69,6 +70,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L var showModelLoadErrorDialog by mutableStateOf(false) var modelLoadError by mutableStateOf("") + // LoRA mode state + var isLoraMode by mutableStateOf(false) + private set + var availableModels by mutableStateOf>(emptyList()) + private set + var activeModelId by mutableStateOf("") + private set + + // Map of loaded LlmModules by model ID for LoRA mode + private val loadedModules = mutableMapOf() + private var module: LlmModule? = null private var resultMessage: Message? = null private val demoSharedPreferences = DemoSharedPreferences(application) @@ -108,18 +120,30 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L val updatedSettingsFields = demoSharedPreferences.getModuleSettings() val isUpdated = currentSettingsFields != updatedSettingsFields val isLoadModel = updatedSettingsFields.isLoadModel + + // Update LoRA mode state + isLoraMode = updatedSettingsFields.isLoraMode + availableModels = updatedSettingsFields.models + activeModelId = updatedSettingsFields.activeModelId + if (isUpdated) { checkForClearChatHistory(updatedSettingsFields) // Update media capabilities after settings are updated setBackendMode(updatedSettingsFields.backendType) if (isLoadModel) { - loadLocalModelAndParameters( - updatedSettingsFields.modelFilePath, - updatedSettingsFields.tokenizerFilePath, - updatedSettingsFields.dataPath, - updatedSettingsFields.temperature.toFloat() - ) + if (isLoraMode && updatedSettingsFields.hasModels()) { + // LoRA mode: Load all configured models + loadLoraModels(updatedSettingsFields) + } else { + // Legacy single-model mode + loadLocalModelAndParameters( + updatedSettingsFields.modelFilePath, + updatedSettingsFields.tokenizerFilePath, + updatedSettingsFields.dataPath, + updatedSettingsFields.temperature.toFloat() + ) + } // Save with isLoadModel = false and update local copy to match, // preventing duplicate "To get started..." messages on subsequent calls val settingsWithLoadFlagCleared = updatedSettingsFields.copy(isLoadModel = false) @@ -127,7 +151,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L currentSettingsFields = settingsWithLoadFlagCleared } else { currentSettingsFields = updatedSettingsFields.copy() - if (module == null) { + if (module == null && loadedModules.isEmpty()) { addSystemMessage(systemPromptMessage) } } @@ -137,8 +161,174 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L val modelPath = updatedSettingsFields.modelFilePath val tokenizerPath = updatedSettingsFields.tokenizerFilePath if (modelPath.isEmpty() || tokenizerPath.isEmpty()) { - addSystemMessage(systemPromptMessage) + if (!isLoraMode || !updatedSettingsFields.hasModels()) { + addSystemMessage(systemPromptMessage) + } + } + } + } + + /** + * Loads all models configured in LoRA mode. + */ + private fun loadLoraModels(settings: ModuleSettings) { + Thread { + val sharedDataPath = settings.getEffectiveDataPath() + val modelLoadingMessage = Message("Loading ${settings.models.size} model(s) for LoRA...", false, MessageType.SYSTEM, 0) + _messages.add(modelLoadingMessage) + isModelReady = false + + var loadedCount = 0 + var firstLoadedModelId: String? = null + + for (modelConfig in settings.models) { + if (!modelConfig.isValid()) continue + + try { + ETLogging.getInstance().log( + "LoRA: Loading model ${modelConfig.displayName} with tokenizer ${modelConfig.tokenizerFilePath} data path $sharedDataPath" + ) + + val runStartTime = System.currentTimeMillis() + val llmModule = if (sharedDataPath.isEmpty()) { + LlmModule( + ModelUtils.getModelCategory(modelConfig.modelType, modelConfig.backendType), + modelConfig.modelFilePath, + modelConfig.tokenizerFilePath, + modelConfig.temperature.toFloat() + ) + } else { + LlmModule( + ModelUtils.getModelCategory(modelConfig.modelType, modelConfig.backendType), + modelConfig.modelFilePath, + modelConfig.tokenizerFilePath, + modelConfig.temperature.toFloat(), + sharedDataPath + ) + } + + llmModule.load() + val loadDuration = System.currentTimeMillis() - runStartTime + + // Store in map + loadedModules[modelConfig.id] = llmModule + loadedCount++ + + if (firstLoadedModelId == null) { + firstLoadedModelId = modelConfig.id + } + + ETLogging.getInstance().log( + "LoRA: Loaded ${modelConfig.displayName} in ${loadDuration.toFloat() / 1000} sec" + ) + } catch (e: ExecutorchRuntimeException) { + ETLogging.getInstance().log("LoRA: Failed to load ${modelConfig.displayName}: ${e.message}") + _messages.add(Message("Failed to load ${modelConfig.displayName}: ${e.message}", false, MessageType.SYSTEM, 0)) + } } + + _messages.remove(modelLoadingMessage) + + if (loadedCount > 0) { + // Set the active module + val activeId = if (settings.activeModelId.isNotEmpty() && loadedModules.containsKey(settings.activeModelId)) { + settings.activeModelId + } else { + firstLoadedModelId ?: "" + } + + activeModelId = activeId + module = loadedModules[activeId] + + val activeModelName = settings.getModelById(activeId)?.displayName ?: "Unknown" + _messages.add(Message( + "Successfully loaded $loadedCount model(s). Active: $activeModelName. Use the switch button to change models.", + false, MessageType.SYSTEM, 0 + )) + isModelReady = true + } else { + _messages.add(Message("No models loaded. Please check your configuration.", false, MessageType.SYSTEM, 0)) + isModelReady = false + } + }.start() + } + + /** + * Switches to a different model in LoRA mode. + * Creates a new LlmModule with the selected PTE and the same PTD. + */ + fun switchToModel(modelId: String) { + if (!isLoraMode) return + if (isGenerating) { + addSystemMessage("Cannot switch models while generating. Please wait or stop generation.") + return + } + + val modelConfig = currentSettingsFields.getModelById(modelId) + if (modelConfig == null) { + addSystemMessage("Model not found.") + return + } + + // Check if model is already loaded + if (loadedModules.containsKey(modelId)) { + // Just switch to the already loaded module + module = loadedModules[modelId] + activeModelId = modelId + + // Update settings with new active model + currentSettingsFields = currentSettingsFields.setActiveModel(modelId) + demoSharedPreferences.saveModuleSettings(currentSettingsFields) + + addSystemMessage("Switched to ${modelConfig.displayName}") + ETLogging.getInstance().log("LoRA: Switched to already loaded model ${modelConfig.displayName}") + } else { + // Need to load the model first + Thread { + val sharedDataPath = currentSettingsFields.getEffectiveDataPath() + addSystemMessage("Loading ${modelConfig.displayName}...") + isModelReady = false + + try { + val runStartTime = System.currentTimeMillis() + val llmModule = if (sharedDataPath.isEmpty()) { + LlmModule( + ModelUtils.getModelCategory(modelConfig.modelType, modelConfig.backendType), + modelConfig.modelFilePath, + modelConfig.tokenizerFilePath, + modelConfig.temperature.toFloat() + ) + } else { + LlmModule( + ModelUtils.getModelCategory(modelConfig.modelType, modelConfig.backendType), + modelConfig.modelFilePath, + modelConfig.tokenizerFilePath, + modelConfig.temperature.toFloat(), + sharedDataPath + ) + } + + llmModule.load() + val loadDuration = System.currentTimeMillis() - runStartTime + + // Store and switch + loadedModules[modelId] = llmModule + module = llmModule + activeModelId = modelId + + // Update settings + currentSettingsFields = currentSettingsFields.setActiveModel(modelId) + demoSharedPreferences.saveModuleSettings(currentSettingsFields) + + addSystemMessage("Switched to ${modelConfig.displayName} (loaded in ${loadDuration.toFloat() / 1000} sec)") + ETLogging.getInstance().log("LoRA: Loaded and switched to ${modelConfig.displayName} in ${loadDuration.toFloat() / 1000} sec") + isModelReady = true + } catch (e: ExecutorchRuntimeException) { + addSystemMessage("Failed to load ${modelConfig.displayName}: ${e.message}") + ETLogging.getInstance().log("LoRA: Failed to load ${modelConfig.displayName}: ${e.message}") + isModelReady = loadedModules.isNotEmpty() + } + }.start() } } diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt index 67c3eb21..0dd84404 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt @@ -17,6 +17,7 @@ import com.example.executorchllamademo.AppearanceMode import com.example.executorchllamademo.AppSettings import com.example.executorchllamademo.BackendType import com.example.executorchllamademo.DemoSharedPreferences +import com.example.executorchllamademo.ModelConfiguration import com.example.executorchllamademo.ModelSettingsActivity import com.example.executorchllamademo.ModelType import com.example.executorchllamademo.ModuleSettings @@ -41,6 +42,23 @@ class ModelSettingsViewModel : ViewModel() { var showResetUserPromptDialog by mutableStateOf(false) var showInvalidPromptDialog by mutableStateOf(false) var showAppearanceDialog by mutableStateOf(false) + var showAddModelDialog by mutableStateOf(false) + var showRemoveModelDialog by mutableStateOf(false) + var showMemoryWarningDialog by mutableStateOf(false) + + // Add model flow state + var addModelStep by mutableStateOf(0) + private set + var tempModelPath by mutableStateOf("") + private set + var tempTokenizerPath by mutableStateOf("") + private set + var tempModelType by mutableStateOf(ModelType.LLAMA_3) + private set + + // Model to be removed (for confirmation dialog) + var modelToRemove by mutableStateOf(null) + private set // File lists for dialogs var modelFiles by mutableStateOf>(emptyArray()) @@ -60,7 +78,12 @@ class ModelSettingsViewModel : ViewModel() { private fun loadSettings() { demoSharedPreferences?.let { prefs -> - moduleSettings = prefs.getModuleSettings() + var settings = prefs.getModuleSettings() + // Only migrate to multi-model if LoRA mode is enabled + if (settings.isLoraMode) { + settings = settings.migrateToMultiModel() + } + moduleSettings = settings appSettings = prefs.getAppSettings() } } @@ -94,7 +117,7 @@ class ModelSettingsViewModel : ViewModel() { } } - // Model selection + // Model selection (legacy single-model mode) fun selectModel(modelPath: String) { var newSettings = moduleSettings.copy(modelFilePath = modelPath) newSettings = autoSelectModelType(newSettings, modelPath) @@ -118,9 +141,12 @@ class ModelSettingsViewModel : ViewModel() { moduleSettings = moduleSettings.copy(tokenizerFilePath = tokenizerPath) } - // Data path selection + // Data path selection (shared LoRA data path) fun selectDataPath(dataPath: String) { - moduleSettings = moduleSettings.copy(dataPath = dataPath) + moduleSettings = moduleSettings.copy( + dataPath = dataPath, + sharedDataPath = dataPath + ) } // Model type selection @@ -180,8 +206,13 @@ class ModelSettingsViewModel : ViewModel() { saveSettings() } - // Validation + // Validation - considers both legacy and multi-model modes fun isLoadModelEnabled(): Boolean { + // Check multi-model mode first + if (moduleSettings.hasModels()) { + return moduleSettings.models.any { it.isValid() } + } + // Fall back to legacy single-model mode return moduleSettings.modelFilePath.isNotEmpty() && moduleSettings.tokenizerFilePath.isNotEmpty() } @@ -198,4 +229,161 @@ class ModelSettingsViewModel : ViewModel() { appSettings = appSettings.copy(appearanceMode = mode) demoSharedPreferences?.saveAppSettings(appSettings) } + + // ========== LoRA Mode Toggle ========== + + /** + * Toggles LoRA mode on/off. + * When enabled, allows multiple model selection. + * When disabled, uses legacy single-model selection. + */ + fun toggleLoraMode(enabled: Boolean) { + moduleSettings = moduleSettings.copy(isLoraMode = enabled) + } + + // ========== Multi-Model Support Methods ========== + + /** + * Starts the add model flow. + */ + fun startAddModel() { + tempModelPath = "" + tempTokenizerPath = "" + tempModelType = ModelType.LLAMA_3 + addModelStep = 1 + showAddModelDialog = true + refreshFileLists() + } + + /** + * Handles model selection in add model flow (step 1). + */ + fun selectTempModel(modelPath: String) { + tempModelPath = modelPath + // Auto-detect model type + val detectedType = ModelType.fromFilePath(modelPath) + if (detectedType != null) { + tempModelType = detectedType + } + addModelStep = 2 + } + + /** + * Handles tokenizer selection in add model flow (step 2). + */ + fun selectTempTokenizer(tokenizerPath: String) { + tempTokenizerPath = tokenizerPath + addModelStep = 3 + } + + /** + * Handles model type confirmation in add model flow (step 3). + */ + fun selectTempModelType(modelType: ModelType) { + tempModelType = modelType + } + + /** + * Confirms and adds the new model. + */ + fun confirmAddModel() { + if (tempModelPath.isEmpty() || tempTokenizerPath.isEmpty()) return + + val newModel = ModelConfiguration.create( + modelFilePath = tempModelPath, + tokenizerFilePath = tempTokenizerPath, + modelType = tempModelType, + backendType = moduleSettings.backendType, + temperature = ModuleSettings.DEFAULT_TEMPERATURE + ) + + moduleSettings = moduleSettings.addModel(newModel) + cancelAddModel() + } + + /** + * Cancels the add model flow. + */ + fun cancelAddModel() { + showAddModelDialog = false + addModelStep = 0 + tempModelPath = "" + tempTokenizerPath = "" + tempModelType = ModelType.LLAMA_3 + } + + /** + * Goes back to previous step in add model flow. + */ + fun previousAddModelStep() { + when (addModelStep) { + 2 -> { + addModelStep = 1 + tempTokenizerPath = "" + } + 3 -> { + addModelStep = 2 + } + else -> cancelAddModel() + } + } + + /** + * Selects a model as active. + */ + fun selectActiveModel(modelId: String) { + moduleSettings = moduleSettings.setActiveModel(modelId) + } + + /** + * Initiates model removal (shows confirmation). + */ + fun initiateRemoveModel(modelId: String) { + modelToRemove = modelId + showRemoveModelDialog = true + } + + /** + * Confirms model removal. + */ + fun confirmRemoveModel() { + modelToRemove?.let { modelId -> + moduleSettings = moduleSettings.removeModel(modelId) + } + cancelRemoveModel() + } + + /** + * Cancels model removal. + */ + fun cancelRemoveModel() { + showRemoveModelDialog = false + modelToRemove = null + } + + /** + * Checks if load should show memory warning (more than 2 models). + */ + fun shouldShowMemoryWarning(): Boolean { + return moduleSettings.models.size > 2 + } + + /** + * Initiates load models action (may show memory warning first). + */ + fun initiateLoadModels() { + if (shouldShowMemoryWarning()) { + showMemoryWarningDialog = true + } else { + showLoadModelDialog = true + } + } + + /** + * Proceeds with loading after memory warning. + */ + fun proceedAfterMemoryWarning() { + showMemoryWarningDialog = false + showLoadModelDialog = true + } } From c91653fef5bdf197a4a2b9757f28d8d3a5091efc Mon Sep 17 00:00:00 2001 From: Hansong Zhang Date: Wed, 28 Jan 2026 17:51:36 -0800 Subject: [PATCH 02/14] Remove unused onStartChatClick parameter from WelcomeScreen --- .../example/executorchllamademo/ui/screens/WelcomeScreen.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/WelcomeScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/WelcomeScreen.kt index 9a51282b..31fdab0b 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/WelcomeScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/WelcomeScreen.kt @@ -43,8 +43,7 @@ import com.example.executorchllamademo.ui.theme.LocalAppColors fun WelcomeScreen( onLoadModelClick: () -> Unit = {}, onDownloadModelClick: () -> Unit = {}, - onAppSettingsClick: () -> Unit = {}, - onStartChatClick: () -> Unit = {} + onAppSettingsClick: () -> Unit = {} ) { val appColors = LocalAppColors.current val scrollState = rememberScrollState() From c4c28ef4bf7be950c8fa7fb210d60026cbb0f48c Mon Sep 17 00:00:00 2001 From: hsz Date: Tue, 3 Feb 2026 10:41:39 -0800 Subject: [PATCH 03/14] update --- .../executorchllamademo/ModelConfiguration.kt | 3 +- .../executorchllamademo/ModuleSettings.kt | 8 +- .../executorchllamademo/WelcomeActivity.kt | 3 - .../ui/screens/ModelSettingsScreen.kt | 125 ++++++++++++++++++ .../ui/viewmodel/ChatViewModel.kt | 63 +++++---- .../ui/viewmodel/ModelSettingsViewModel.kt | 39 +++++- llm/android/LlamaDemo/gradle.properties | 1 + 7 files changed, 202 insertions(+), 40 deletions(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModelConfiguration.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModelConfiguration.kt index 1f80bd65..c9109d94 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModelConfiguration.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModelConfiguration.kt @@ -19,7 +19,8 @@ data class ModelConfiguration( val modelType: ModelType = ModelType.LLAMA_3, val backendType: BackendType = BackendType.XNNPACK, val temperature: Double = ModuleSettings.DEFAULT_TEMPERATURE, - val displayName: String = "" + val displayName: String = "", + val adapterFilePaths: List = emptyList() ) { companion object { fun create( diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt index be8e5004..e4595f15 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt @@ -28,6 +28,9 @@ data class ModuleSettings( // LoRA mode toggle - when enabled, allows multiple model selection val isLoraMode: Boolean = false, + // Foundation PTD path - shared base weights for all LoRA models + val foundationDataPath: String = "", + // Multi-model support fields (used when isLoraMode is true) val models: List = emptyList(), val activeModelId: String = "", @@ -72,10 +75,11 @@ data class ModuleSettings( } /** - * Gets the effective shared data path (falls back to legacy dataPath). + * Gets the effective foundation data path for LoRA mode. + * Priority: foundationDataPath > sharedDataPath > dataPath */ fun getEffectiveDataPath(): String { - return sharedDataPath.ifEmpty { dataPath } + return foundationDataPath.ifEmpty { sharedDataPath.ifEmpty { dataPath } } } /** diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/WelcomeActivity.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/WelcomeActivity.kt index b830e12c..1f967157 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/WelcomeActivity.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/WelcomeActivity.kt @@ -56,9 +56,6 @@ class WelcomeActivity : ComponentActivity() { }, onAppSettingsClick = { startActivity(Intent(this@WelcomeActivity, AppSettingsActivity::class.java)) - }, - onStartChatClick = { - startActivity(Intent(this@WelcomeActivity, MainActivity::class.java)) } ) } diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt index 3de4b7ae..99de711d 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt @@ -163,6 +163,19 @@ fun ModelSettingsScreen( // ========== Conditional UI based on LoRA mode ========== if (viewModel.moduleSettings.isLoraMode) { + // Foundation PTD selector (required for LoRA) + SettingsRow( + label = "Foundation PTD", + value = viewModel.getFilenameFromPath(viewModel.moduleSettings.foundationDataPath) + .ifEmpty { "no foundation PTD selected" }, + onClick = { + viewModel.refreshFileLists() + viewModel.showFoundationDataPathDialog = true + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + // LoRA Mode: Multi-model selection Text( text = "Models", @@ -440,6 +453,7 @@ fun ModelSettingsScreen( ModelDialog(viewModel) TokenizerDialog(viewModel) DataPathDialog(viewModel) + FoundationDataPathDialog(viewModel) ModelTypeDialog(viewModel) LoadModelDialog(viewModel, onLoadModel, onBackPressed) ResetSystemPromptDialog(viewModel) @@ -618,6 +632,36 @@ private fun DataPathDialog(viewModel: ModelSettingsViewModel) { } } +@Composable +private fun FoundationDataPathDialog(viewModel: ModelSettingsViewModel) { + if (viewModel.showFoundationDataPathDialog) { + if (viewModel.dataPathFiles.isEmpty()) { + AlertDialog( + onDismissRequest = { viewModel.showFoundationDataPathDialog = false }, + title = { Text("Select Foundation PTD") }, + text = { + Text("No PTD files (.ptd) found in /data/local/tmp/llama/\n\nPlease push foundation PTD file using:\nadb push .ptd /data/local/tmp/llama/") + }, + confirmButton = { + TextButton(onClick = { viewModel.showFoundationDataPathDialog = false }) { + Text("OK") + } + } + ) + } else { + SingleChoiceDialog( + title = "Select Foundation PTD", + options = viewModel.dataPathFiles.toList(), + onSelect = { selected -> + viewModel.selectFoundationDataPath(selected) + viewModel.showFoundationDataPathDialog = false + }, + onDismiss = { viewModel.showFoundationDataPathDialog = false } + ) + } + } +} + @Composable private fun ModelTypeDialog(viewModel: ModelSettingsViewModel) { if (viewModel.showModelTypeDialog) { @@ -914,6 +958,87 @@ private fun AddModelDialog(viewModel: ModelSettingsViewModel) { } } }, + confirmButton = { + TextButton(onClick = { viewModel.goToAddModelStep(4) }) { + Text("Next") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.previousAddModelStep() }) { + Text("Back") + } + } + ) + } + 4 -> { + // Step 4: Select adapter PTDs (optional, multi-select) + val appColors = LocalAppColors.current + + AlertDialog( + onDismissRequest = { viewModel.previousAddModelStep() }, + title = { Text("Step 4: Select Adapter PTDs (Optional)") }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + ) { + Text( + text = "Select one or more adapter PTD files for this model:", + fontSize = 14.sp, + modifier = Modifier.padding(bottom = 8.dp) + ) + + if (viewModel.dataPathFiles.isEmpty()) { + Text( + text = "No .ptd files found in /data/local/tmp/llama/", + fontSize = 12.sp, + color = appColors.settingsSecondaryText + ) + } else { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + viewModel.dataPathFiles.forEach { adapterPath -> + val isSelected = viewModel.tempAdapterPaths.contains(adapterPath) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (isSelected) { + viewModel.removeTempAdapter(adapterPath) + } else { + viewModel.addTempAdapter(adapterPath) + } + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + androidx.compose.material3.Checkbox( + checked = isSelected, + onCheckedChange = null + ) + Text( + text = adapterPath.substringAfterLast('/'), + modifier = Modifier.padding(start = 8.dp), + fontSize = 14.sp + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Selected: ${viewModel.tempAdapterPaths.size} adapter(s)", + fontSize = 12.sp, + color = appColors.settingsSecondaryText + ) + } + }, confirmButton = { TextButton(onClick = { viewModel.confirmAddModel() }) { Text("Add Model") diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt index 44b01487..d7a44a71 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt @@ -214,27 +214,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L if (!modelConfig.isValid()) continue try { + // Build dataFiles list: foundation PTD + adapter PTDs + val dataFiles = mutableListOf() + if (sharedDataPath.isNotEmpty()) { + dataFiles.add(sharedDataPath) + } + dataFiles.addAll(modelConfig.adapterFilePaths) + + val dataFilesLog = if (dataFiles.isEmpty()) "no data files" else dataFiles.joinToString(", ") ETLogging.getInstance().log( - "LoRA: Loading model ${modelConfig.displayName} with tokenizer ${modelConfig.tokenizerFilePath} data path $sharedDataPath" + "LoRA: Loading model ${modelConfig.displayName} with tokenizer ${modelConfig.tokenizerFilePath}, data files: $dataFilesLog" ) val runStartTime = System.currentTimeMillis() - val llmModule = if (sharedDataPath.isEmpty()) { - LlmModule( - ModelUtils.getModelCategory(modelConfig.modelType, modelConfig.backendType), - modelConfig.modelFilePath, - modelConfig.tokenizerFilePath, - modelConfig.temperature.toFloat() - ) - } else { - LlmModule( - ModelUtils.getModelCategory(modelConfig.modelType, modelConfig.backendType), - modelConfig.modelFilePath, - modelConfig.tokenizerFilePath, - modelConfig.temperature.toFloat(), - sharedDataPath - ) - } + val llmModule = LlmModule( + ModelUtils.getModelCategory(modelConfig.modelType, modelConfig.backendType), + modelConfig.modelFilePath, + modelConfig.tokenizerFilePath, + modelConfig.temperature.toFloat(), + dataFiles + ) llmModule.load() val loadDuration = System.currentTimeMillis() - runStartTime @@ -319,23 +318,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L isModelReady = false try { - val runStartTime = System.currentTimeMillis() - val llmModule = if (sharedDataPath.isEmpty()) { - LlmModule( - ModelUtils.getModelCategory(modelConfig.modelType, modelConfig.backendType), - modelConfig.modelFilePath, - modelConfig.tokenizerFilePath, - modelConfig.temperature.toFloat() - ) - } else { - LlmModule( - ModelUtils.getModelCategory(modelConfig.modelType, modelConfig.backendType), - modelConfig.modelFilePath, - modelConfig.tokenizerFilePath, - modelConfig.temperature.toFloat(), - sharedDataPath - ) + // Build dataFiles list: foundation PTD + adapter PTDs + val dataFiles = mutableListOf() + if (sharedDataPath.isNotEmpty()) { + dataFiles.add(sharedDataPath) } + dataFiles.addAll(modelConfig.adapterFilePaths) + + val runStartTime = System.currentTimeMillis() + val llmModule = LlmModule( + ModelUtils.getModelCategory(modelConfig.modelType, modelConfig.backendType), + modelConfig.modelFilePath, + modelConfig.tokenizerFilePath, + modelConfig.temperature.toFloat(), + dataFiles + ) llmModule.load() val loadDuration = System.currentTimeMillis() - runStartTime diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt index 0dd84404..3b007fe5 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt @@ -36,6 +36,8 @@ class ModelSettingsViewModel : ViewModel() { var showModelDialog by mutableStateOf(false) var showTokenizerDialog by mutableStateOf(false) var showDataPathDialog by mutableStateOf(false) + var showFoundationDataPathDialog by mutableStateOf(false) + var showAdapterDialog by mutableStateOf(false) var showModelTypeDialog by mutableStateOf(false) var showLoadModelDialog by mutableStateOf(false) var showResetSystemPromptDialog by mutableStateOf(false) @@ -55,6 +57,8 @@ class ModelSettingsViewModel : ViewModel() { private set var tempModelType by mutableStateOf(ModelType.LLAMA_3) private set + var tempAdapterPaths by mutableStateOf>(emptyList()) + private set // Model to be removed (for confirmation dialog) var modelToRemove by mutableStateOf(null) @@ -149,6 +153,11 @@ class ModelSettingsViewModel : ViewModel() { ) } + // Foundation PTD selection (for LoRA mode) + fun selectFoundationDataPath(dataPath: String) { + moduleSettings = moduleSettings.copy(foundationDataPath = dataPath) + } + // Model type selection fun selectModelType(modelType: ModelType) { moduleSettings = moduleSettings.copy( @@ -250,6 +259,7 @@ class ModelSettingsViewModel : ViewModel() { tempModelPath = "" tempTokenizerPath = "" tempModelType = ModelType.LLAMA_3 + tempAdapterPaths = emptyList() addModelStep = 1 showAddModelDialog = true refreshFileLists() @@ -283,6 +293,13 @@ class ModelSettingsViewModel : ViewModel() { tempModelType = modelType } + /** + * Sets the add model step (for navigation). + */ + fun goToAddModelStep(step: Int) { + addModelStep = step + } + /** * Confirms and adds the new model. */ @@ -295,7 +312,7 @@ class ModelSettingsViewModel : ViewModel() { modelType = tempModelType, backendType = moduleSettings.backendType, temperature = ModuleSettings.DEFAULT_TEMPERATURE - ) + ).copy(adapterFilePaths = tempAdapterPaths) moduleSettings = moduleSettings.addModel(newModel) cancelAddModel() @@ -310,6 +327,7 @@ class ModelSettingsViewModel : ViewModel() { tempModelPath = "" tempTokenizerPath = "" tempModelType = ModelType.LLAMA_3 + tempAdapterPaths = emptyList() } /** @@ -324,6 +342,9 @@ class ModelSettingsViewModel : ViewModel() { 3 -> { addModelStep = 2 } + 4 -> { + addModelStep = 3 + } else -> cancelAddModel() } } @@ -386,4 +407,20 @@ class ModelSettingsViewModel : ViewModel() { showMemoryWarningDialog = false showLoadModelDialog = true } + + /** + * Adds an adapter PTD to the temp adapter list. + */ + fun addTempAdapter(adapterPath: String) { + if (adapterPath.isNotEmpty() && !tempAdapterPaths.contains(adapterPath)) { + tempAdapterPaths = tempAdapterPaths + adapterPath + } + } + + /** + * Removes an adapter PTD from the temp adapter list. + */ + fun removeTempAdapter(adapterPath: String) { + tempAdapterPaths = tempAdapterPaths.filter { it != adapterPath } + } } diff --git a/llm/android/LlamaDemo/gradle.properties b/llm/android/LlamaDemo/gradle.properties index 2cbd6d19..e5cfff61 100644 --- a/llm/android/LlamaDemo/gradle.properties +++ b/llm/android/LlamaDemo/gradle.properties @@ -21,3 +21,4 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true +useLocalAar=true From 97493b062e68b37a7c513294164eb10dd63741bc Mon Sep 17 00:00:00 2001 From: hsz Date: Tue, 3 Feb 2026 14:16:18 -0800 Subject: [PATCH 04/14] update --- .../ui/screens/ModelSettingsScreen.kt | 53 ++----------------- .../ui/viewmodel/ModelSettingsViewModel.kt | 10 ++-- 2 files changed, 9 insertions(+), 54 deletions(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt index 99de711d..15e56cd0 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt @@ -924,59 +924,12 @@ private fun AddModelDialog(viewModel: ModelSettingsViewModel) { } } 3 -> { - // Step 3: Confirm model type - val modelTypes = ModelType.values().map { it.toString() } - val preSelectedIndex = ModelType.values().indexOfFirst { it == viewModel.tempModelType } - - AlertDialog( - onDismissRequest = { viewModel.previousAddModelStep() }, - title = { Text("Step 3: Confirm Model Type") }, - text = { - Column { - modelTypes.forEachIndexed { index, option -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - viewModel.selectTempModelType(ModelType.valueOf(option)) - } - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = index == preSelectedIndex || viewModel.tempModelType.toString() == option, - onClick = { - viewModel.selectTempModelType(ModelType.valueOf(option)) - } - ) - Text( - text = option, - modifier = Modifier.padding(start = 8.dp), - fontSize = 14.sp - ) - } - } - } - }, - confirmButton = { - TextButton(onClick = { viewModel.goToAddModelStep(4) }) { - Text("Next") - } - }, - dismissButton = { - TextButton(onClick = { viewModel.previousAddModelStep() }) { - Text("Back") - } - } - ) - } - 4 -> { - // Step 4: Select adapter PTDs (optional, multi-select) + // Step 3: Select adapter PTDs (0 or more, optional) val appColors = LocalAppColors.current AlertDialog( onDismissRequest = { viewModel.previousAddModelStep() }, - title = { Text("Step 4: Select Adapter PTDs (Optional)") }, + title = { Text("Step 3: Select Adapter PTDs") }, text = { Column( modifier = Modifier @@ -984,7 +937,7 @@ private fun AddModelDialog(viewModel: ModelSettingsViewModel) { .height(400.dp) ) { Text( - text = "Select one or more adapter PTD files for this model:", + text = "Select 0 or more adapter PTD files for this model (optional):", fontSize = 14.sp, modifier = Modifier.padding(bottom = 8.dp) ) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt index 3b007fe5..bab31ce3 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt @@ -283,7 +283,12 @@ class ModelSettingsViewModel : ViewModel() { */ fun selectTempTokenizer(tokenizerPath: String) { tempTokenizerPath = tokenizerPath - addModelStep = 3 + // Auto-detect model type from PTE filename + val detectedType = ModelType.fromFilePath(tempModelPath) + if (detectedType != null) { + tempModelType = detectedType + } + addModelStep = 3 // Skip model type, go to adapters } /** @@ -342,9 +347,6 @@ class ModelSettingsViewModel : ViewModel() { 3 -> { addModelStep = 2 } - 4 -> { - addModelStep = 3 - } else -> cancelAddModel() } } From 26ab5972be5c7b7b55c0beba3fb056c31b4621ab Mon Sep 17 00:00:00 2001 From: hsz Date: Tue, 3 Feb 2026 14:31:12 -0800 Subject: [PATCH 05/14] Fi --- .../ui/screens/ModelSettingsScreen.kt | 13 ------------ .../ui/viewmodel/ChatViewModel.kt | 20 ++++++++++++++++++- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt index 15e56cd0..4ed2d8f4 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt @@ -221,19 +221,6 @@ fun ModelSettingsScreen( Spacer(modifier = Modifier.height(16.dp)) - // Shared Data Path (LoRA) selector - SettingsRow( - label = "Shared Data Path (LoRA)", - value = viewModel.getFilenameFromPath(viewModel.moduleSettings.getEffectiveDataPath()) - .ifEmpty { "no data path selected" }, - onClick = { - viewModel.refreshFileLists() - viewModel.showDataPathDialog = true - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - // Load All Models button Button( onClick = { viewModel.initiateLoadModels() }, diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt index d7a44a71..a4b89adc 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt @@ -203,7 +203,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L private fun loadLoraModels(settings: ModuleSettings) { Thread { val sharedDataPath = settings.getEffectiveDataPath() - val modelLoadingMessage = Message("Loading ${settings.models.size} model(s) for LoRA...", false, MessageType.SYSTEM, 0) + + // Build detailed loading message with args for each model + val loadingDetails = StringBuilder("Loading ${settings.models.size} LoRA model(s):\n") + settings.models.forEachIndexed { index, modelConfig -> + if (modelConfig.isValid()) { + val dataFiles = mutableListOf() + if (sharedDataPath.isNotEmpty()) { + dataFiles.add(sharedDataPath) + } + dataFiles.addAll(modelConfig.adapterFilePaths) + + loadingDetails.append("\n${index + 1}. ${modelConfig.displayName}\n") + loadingDetails.append(" PTE: ${modelConfig.modelFilePath.substringAfterLast('/')}\n") + loadingDetails.append(" Tokenizer: ${modelConfig.tokenizerFilePath.substringAfterLast('/')}\n") + loadingDetails.append(" Data files: ${if (dataFiles.isEmpty()) "none" else dataFiles.joinToString(", ") { it.substringAfterLast('/') }}\n") + } + } + + val modelLoadingMessage = Message(loadingDetails.toString(), false, MessageType.SYSTEM, 0) _messages.add(modelLoadingMessage) isModelReady = false From ce84fee2b1adb7587f68a57ba276ce5ecb8b806e Mon Sep 17 00:00:00 2001 From: hsz Date: Tue, 3 Feb 2026 14:36:38 -0800 Subject: [PATCH 06/14] Fix --- .../com/example/executorchllamademo/ModuleSettings.kt | 3 ++- .../ui/screens/ModelSettingsScreen.kt | 11 +++++++++++ .../ui/viewmodel/ModelSettingsViewModel.kt | 7 +++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt index e4595f15..4bb02ade 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt @@ -34,7 +34,8 @@ data class ModuleSettings( // Multi-model support fields (used when isLoraMode is true) val models: List = emptyList(), val activeModelId: String = "", - val sharedDataPath: String = "" + val sharedDataPath: String = "", + val foundationModelType: ModelType = ModelType.LLAMA_3_2 ) { /** * Gets the effective model type, considering multi-model configuration. diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt index 4ed2d8f4..be5aab5f 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt @@ -174,6 +174,17 @@ fun ModelSettingsScreen( } ) + Spacer(modifier = Modifier.height(8.dp)) + + // Foundation Model Type selector (for LoRA) + SettingsRow( + label = "Foundation Model Type", + value = viewModel.moduleSettings.foundationModelType.toString(), + onClick = { + viewModel.showFoundationModelTypeDialog = true + } + ) + Spacer(modifier = Modifier.height(16.dp)) // LoRA Mode: Multi-model selection diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt index bab31ce3..6348b765 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt @@ -37,6 +37,7 @@ class ModelSettingsViewModel : ViewModel() { var showTokenizerDialog by mutableStateOf(false) var showDataPathDialog by mutableStateOf(false) var showFoundationDataPathDialog by mutableStateOf(false) + var showFoundationModelTypeDialog by mutableStateOf(false) var showAdapterDialog by mutableStateOf(false) var showModelTypeDialog by mutableStateOf(false) var showLoadModelDialog by mutableStateOf(false) @@ -158,6 +159,12 @@ class ModelSettingsViewModel : ViewModel() { moduleSettings = moduleSettings.copy(foundationDataPath = dataPath) } + // Foundation Model Type selection (for LoRA mode) + fun selectFoundationModelType(modelType: ModelType) { + moduleSettings = moduleSettings.copy(foundationModelType = modelType) + showFoundationModelTypeDialog = false + } + // Model type selection fun selectModelType(modelType: ModelType) { moduleSettings = moduleSettings.copy( From dc3bd321628a9b479b7874b9a6f0eaadb0c3699a Mon Sep 17 00:00:00 2001 From: hsz Date: Tue, 3 Feb 2026 14:38:02 -0800 Subject: [PATCH 07/14] Fix --- .../example/executorchllamademo/ModuleSettings.kt | 2 +- .../ui/screens/ModelSettingsScreen.kt | 15 +++++++++++++++ .../ui/viewmodel/ModelSettingsViewModel.kt | 9 +++------ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt index 4bb02ade..812d5905 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt @@ -35,7 +35,7 @@ data class ModuleSettings( val models: List = emptyList(), val activeModelId: String = "", val sharedDataPath: String = "", - val foundationModelType: ModelType = ModelType.LLAMA_3_2 + val foundationModelType: ModelType = ModelType.LLAMA_3 ) { /** * Gets the effective model type, considering multi-model configuration. diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt index be5aab5f..3fc9acea 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt @@ -452,6 +452,7 @@ fun ModelSettingsScreen( TokenizerDialog(viewModel) DataPathDialog(viewModel) FoundationDataPathDialog(viewModel) + FoundationModelTypeDialog(viewModel) ModelTypeDialog(viewModel) LoadModelDialog(viewModel, onLoadModel, onBackPressed) ResetSystemPromptDialog(viewModel) @@ -660,6 +661,20 @@ private fun FoundationDataPathDialog(viewModel: ModelSettingsViewModel) { } } +@Composable +private fun FoundationModelTypeDialog(viewModel: ModelSettingsViewModel) { + if (viewModel.showFoundationModelTypeDialog) { + SingleChoiceDialog( + title = "Select Foundation Model Type", + options = ModelType.values().map { it.toString() }, + onSelect = { selected -> + viewModel.selectFoundationModelType(ModelType.valueOf(selected)) + }, + onDismiss = { viewModel.showFoundationModelTypeDialog = false } + ) + } +} + @Composable private fun ModelTypeDialog(viewModel: ModelSettingsViewModel) { if (viewModel.showModelTypeDialog) { diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt index 6348b765..216aa1a7 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ModelSettingsViewModel.kt @@ -290,12 +290,9 @@ class ModelSettingsViewModel : ViewModel() { */ fun selectTempTokenizer(tokenizerPath: String) { tempTokenizerPath = tokenizerPath - // Auto-detect model type from PTE filename - val detectedType = ModelType.fromFilePath(tempModelPath) - if (detectedType != null) { - tempModelType = detectedType - } - addModelStep = 3 // Skip model type, go to adapters + // Use foundation model type for LoRA mode + tempModelType = moduleSettings.foundationModelType + addModelStep = 3 // Go to adapters } /** From 5df1aee7839bea2a1dfea6df8a0de03c2313f3bc Mon Sep 17 00:00:00 2001 From: hsz Date: Tue, 3 Feb 2026 14:40:47 -0800 Subject: [PATCH 08/14] Fix UI --- .../executorchllamademo/ui/components/ModelListItem.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/components/ModelListItem.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/components/ModelListItem.kt index bc8baaf9..f13da601 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/components/ModelListItem.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/components/ModelListItem.kt @@ -80,15 +80,6 @@ fun ModelListItem( overflow = TextOverflow.Ellipsis ) - // Model type and backend - Text( - text = "${model.modelType} | ${model.backendType}", - color = appColors.settingsSecondaryText, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - // Tokenizer name val tokenizerName = model.tokenizerFilePath.substringAfterLast('/').ifEmpty { "No tokenizer" } Text( From cf7f2b02817d551e54bf5fdae1fd0c8c99414e85 Mon Sep 17 00:00:00 2001 From: hsz Date: Tue, 3 Feb 2026 14:43:45 -0800 Subject: [PATCH 09/14] Fix list UI --- .../executorchllamademo/ui/screens/ModelSettingsScreen.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt index 3fc9acea..28d4a8db 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt @@ -839,7 +839,12 @@ private fun SingleChoiceDialog( onDismissRequest = onDismiss, title = { Text(title) }, text = { - Column { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + .verticalScroll(rememberScrollState()) + ) { options.forEach { option -> Row( modifier = Modifier From 00f4e429febdb286fa0ea1987e3e6d79a4ba82fc Mon Sep 17 00:00:00 2001 From: hsz Date: Tue, 3 Feb 2026 14:44:00 -0800 Subject: [PATCH 10/14] Fix list UI --- .../executorchllamademo/ui/screens/ModelSettingsScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt index 28d4a8db..ca5da54e 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState From c2560bb6d3dd6f7428d448fce4bee82e34fc40dc Mon Sep 17 00:00:00 2001 From: hsz Date: Tue, 3 Feb 2026 14:44:44 -0800 Subject: [PATCH 11/14] Fix list UI --- .../executorchllamademo/ui/screens/ModelSettingsScreen.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt index ca5da54e..7e27a576 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ModelSettingsScreen.kt @@ -1041,7 +1041,12 @@ private fun SingleChoiceDialogWithPreselection( onDismissRequest = onDismiss, title = { Text(title) }, text = { - Column { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + .verticalScroll(rememberScrollState()) + ) { options.forEach { option -> Row( modifier = Modifier From a41d9b2e8b365bc63c6d7f80309033f46ab64312 Mon Sep 17 00:00:00 2001 From: hsz Date: Tue, 3 Feb 2026 14:50:35 -0800 Subject: [PATCH 12/14] Fix media button --- .../executorchllamademo/ui/viewmodel/ChatViewModel.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt index a4b89adc..84606ea4 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt @@ -386,7 +386,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L } private fun updateMediaCapabilities(backendSupportsMedia: Boolean) { - val modelType = currentSettingsFields.modelType + // In LoRA mode, use foundation model type; otherwise use regular model type + val modelType = if (currentSettingsFields.isLoraMode) { + currentSettingsFields.foundationModelType + } else { + currentSettingsFields.modelType + } + supportsImageInput = backendSupportsMedia && modelType.supportsImage() supportsAudioInput = backendSupportsMedia && modelType.supportsAudio() showMediaButtons = supportsImageInput || supportsAudioInput From 96858763f6a2ae8985c4a39c2b3fe53832813877 Mon Sep 17 00:00:00 2001 From: hsz Date: Tue, 3 Feb 2026 14:59:14 -0800 Subject: [PATCH 13/14] Fix --- llm/android/LlamaDemo/gradle.properties | 1 - 1 file changed, 1 deletion(-) diff --git a/llm/android/LlamaDemo/gradle.properties b/llm/android/LlamaDemo/gradle.properties index e5cfff61..2cbd6d19 100644 --- a/llm/android/LlamaDemo/gradle.properties +++ b/llm/android/LlamaDemo/gradle.properties @@ -21,4 +21,3 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -useLocalAar=true From cf70e816906fcc177baa03aac5624db88023a2bd Mon Sep 17 00:00:00 2001 From: Hansong Zhang Date: Tue, 3 Feb 2026 15:18:03 -0800 Subject: [PATCH 14/14] Upgrade --- llm/android/LlamaDemo/app/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/llm/android/LlamaDemo/app/build.gradle.kts b/llm/android/LlamaDemo/app/build.gradle.kts index 615cabba..b4a5a86f 100644 --- a/llm/android/LlamaDemo/app/build.gradle.kts +++ b/llm/android/LlamaDemo/app/build.gradle.kts @@ -268,14 +268,14 @@ dependencies { if (useLocalAar == true) { implementation(files("libs/executorch.aar")) } else { - implementation("org.pytorch:executorch-android:1.0.1") + implementation("org.pytorch:executorch-android:1.1.0") // https://mvnrepository.com/artifact/org.pytorch/executorch-android-qnn // Uncomment this to enable QNN - // implementation("org.pytorch:executorch-android-qnn:1.0.1") + // implementation("org.pytorch:executorch-android-qnn:1.1.0") // https://mvnrepository.com/artifact/org.pytorch/executorch-android-vulkan // uncomment to enable vulkan - // implementation("org.pytorch:executorch-android-vulkan:1.0.1") + // implementation("org.pytorch:executorch-android-vulkan:1.1.0") } implementation("com.google.android.material:material:1.12.0") implementation("androidx.activity:activity:1.9.0")