diff --git a/llm/android/LlamaDemo/app/src/androidTest/java/com/example/executorchllamademo/UIWorkflowTest.kt b/llm/android/LlamaDemo/app/src/androidTest/java/com/example/executorchllamademo/UIWorkflowTest.kt index abb171b1e7..0a4f08cde3 100644 --- a/llm/android/LlamaDemo/app/src/androidTest/java/com/example/executorchllamademo/UIWorkflowTest.kt +++ b/llm/android/LlamaDemo/app/src/androidTest/java/com/example/executorchllamademo/UIWorkflowTest.kt @@ -148,7 +148,6 @@ class UIWorkflowTest { Log.e(TAG, "Model file not found: $modelFile") return false } - composeTestRule.waitForIdle() // Click tokenizer row to open tokenizer selection dialog composeTestRule.onNodeWithText("Tokenizer").performClick() @@ -163,7 +162,6 @@ class UIWorkflowTest { Log.e(TAG, "Tokenizer file not found: $tokenizerFile") return false } - composeTestRule.waitForIdle() // Click Load Model button composeTestRule.onNodeWithText("Load Model").performClick() @@ -173,7 +171,6 @@ class UIWorkflowTest { // Confirm in dialog composeTestRule.onNodeWithText("Yes").performClick() - composeTestRule.waitForIdle() return true } @@ -184,25 +181,23 @@ class UIWorkflowTest { */ private fun waitForModelLoaded(timeoutMs: Long = 60000): Boolean { return try { + var wasSuccess = false composeTestRule.waitUntil(timeoutMillis = timeoutMs) { val successNodes = composeTestRule.onAllNodesWithText("Successfully loaded", substring = true) .fetchSemanticsNodes() - val errorNodes = composeTestRule.onAllNodesWithText("Model Load failure", substring = true) + val errorNodes = composeTestRule.onAllNodesWithText("Model load failure", substring = true) .fetchSemanticsNodes() + wasSuccess = successNodes.isNotEmpty() successNodes.isNotEmpty() || errorNodes.isNotEmpty() } - // Check which one appeared - val successNodes = composeTestRule.onAllNodesWithText("Successfully loaded", substring = true) - .fetchSemanticsNodes() - if (successNodes.isNotEmpty()) { + if (wasSuccess) { Log.i(TAG, "Model loaded successfully") - true } else { Log.e(TAG, "Model load failed") - false } + wasSuccess } catch (e: Exception) { - Log.e(TAG, "Model loading timed out after ${timeoutMs}ms") + Log.e(TAG, "Model loading timed out after ${timeoutMs}ms: ${e.message}") false } } @@ -301,7 +296,6 @@ class UIWorkflowTest { // Select model file composeTestRule.onNodeWithText(modelFile, substring = true).performClick() - composeTestRule.waitForIdle() // Click tokenizer selection composeTestRule.onNodeWithText("Tokenizer").performClick() @@ -311,7 +305,6 @@ class UIWorkflowTest { // Select tokenizer file composeTestRule.onNodeWithText(tokenizerFile, substring = true).performClick() - composeTestRule.waitForIdle() // Click load model button composeTestRule.onNodeWithText("Load Model").performClick() @@ -343,6 +336,17 @@ class UIWorkflowTest { // Type a message using testTag typeInChatInput("tell me a story") + // Verify send button is enabled before clicking + composeTestRule.waitUntil(timeoutMillis = 5025) { + try { + composeTestRule.onNodeWithContentDescription("Send").assertIsEnabled() + true + } catch (e: AssertionError) { + Log.d(TAG, "Send button not yet enabled: ${e.message}") + false + } + } + // Click send composeTestRule.onNodeWithContentDescription("Send").performClick() composeTestRule.waitForIdle() @@ -379,6 +383,17 @@ class UIWorkflowTest { // Type a long prompt using testTag typeInChatInput("Write a very long story about a brave knight who goes on an adventure") + // Verify send button is enabled before clicking + composeTestRule.waitUntil(timeoutMillis = 5026) { + try { + composeTestRule.onNodeWithContentDescription("Send").assertIsEnabled() + true + } catch (e: AssertionError) { + Log.d(TAG, "Send button not yet enabled: ${e.message}") + false + } + } + // Click send composeTestRule.onNodeWithContentDescription("Send").performClick() composeTestRule.waitForIdle() @@ -389,13 +404,17 @@ class UIWorkflowTest { composeTestRule.onAllNodes(hasContentDescription("Stop")) .fetchSemanticsNodes().isNotEmpty() } - // Click stop - composeTestRule.onNodeWithContentDescription("Stop").performClick() } catch (e: Exception) { // Generation might have already finished Log.i(TAG, "Stop button not found - generation may have completed") } + // Give state time to fully synchronize + Thread.sleep(500) + + // Click stop + composeTestRule.onNodeWithContentDescription("Stop").performClick() + composeTestRule.waitForIdle() // Wait for generation to fully stop diff --git a/llm/android/LlamaDemo/app/src/main/AndroidManifest.xml b/llm/android/LlamaDemo/app/src/main/AndroidManifest.xml index 96a3e6485f..22efc6f6b5 100644 --- a/llm/android/LlamaDemo/app/src/main/AndroidManifest.xml +++ b/llm/android/LlamaDemo/app/src/main/AndroidManifest.xml @@ -1,12 +1,6 @@ - - + xmlns:tools="http://schemas.android.com/tools"> @@ -18,7 +12,6 @@ android:name=".ETLogging" android:allowBackup="false" android:dataExtractionRules="@xml/data_extraction_rules" - android:extractNativeLibs="true" android:fullBackupContent="@xml/backup_rules" android:icon="@drawable/logo" android:label="@string/app_name" diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/AppSettings.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/AppSettings.kt new file mode 100644 index 0000000000..6f2cbecac6 --- /dev/null +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/AppSettings.kt @@ -0,0 +1,16 @@ +/* + * 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 + +/** + * Holds app-wide settings that are independent of the current module/model. + */ +data class AppSettings( + val appearanceMode: AppearanceMode = AppearanceMode.SYSTEM +) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/DemoSharedPreferences.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/DemoSharedPreferences.kt index 005204d610..05ea7acac8 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/DemoSharedPreferences.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/DemoSharedPreferences.kt @@ -20,6 +20,10 @@ class DemoSharedPreferences(private val context: Context) { Context.MODE_PRIVATE ) + private val gson = Gson() + + // --- Messages --- + fun getSavedMessages(): String { return sharedPreferences.getString( context.getString(R.string.saved_messages_json_key), @@ -29,7 +33,6 @@ class DemoSharedPreferences(private val context: Context) { fun addMessages(messages: List) { val editor = sharedPreferences.edit() - val gson = Gson() val msgJSON = gson.toJson(messages) editor.putString(context.getString(R.string.saved_messages_json_key), msgJSON) editor.apply() @@ -41,24 +44,50 @@ class DemoSharedPreferences(private val context: Context) { editor.apply() } - fun addSettings(settingsFields: SettingsFields) { + // --- App Settings (app-wide, e.g., appearance) --- + + fun getAppSettings(): AppSettings { + val json = sharedPreferences.getString(PREF_KEY_APP_SETTINGS, null) + return if (json.isNullOrEmpty()) { + AppSettings() + } else { + try { + gson.fromJson(json, AppSettings::class.java) ?: AppSettings() + } catch (e: Exception) { + AppSettings() + } + } + } + + fun saveAppSettings(settings: AppSettings) { val editor = sharedPreferences.edit() - val gson = Gson() - val settingsJSON = gson.toJson(settingsFields) - editor.putString(context.getString(R.string.settings_json_key), settingsJSON) + editor.putString(PREF_KEY_APP_SETTINGS, gson.toJson(settings)) editor.apply() } - fun getSettings(): String { - return sharedPreferences.getString( - context.getString(R.string.settings_json_key), - "" - ) ?: "" + // --- Module Settings (per-model configuration) --- + + fun getModuleSettings(): ModuleSettings { + val json = sharedPreferences.getString(PREF_KEY_MODULE_SETTINGS, null) + return if (json.isNullOrEmpty()) { + ModuleSettings() + } else { + try { + gson.fromJson(json, ModuleSettings::class.java) ?: ModuleSettings() + } catch (e: Exception) { + ModuleSettings() + } + } + } + + fun saveModuleSettings(settings: ModuleSettings) { + val editor = sharedPreferences.edit() + editor.putString(PREF_KEY_MODULE_SETTINGS, gson.toJson(settings)) + editor.apply() } fun saveLogs() { val editor = sharedPreferences.edit() - val gson = Gson() // Create a copy to avoid ConcurrentModificationException if logs are added during serialization val logsCopy = ArrayList(ETLogging.getInstance().getLogs()) val msgJSON = gson.toJson(logsCopy) @@ -80,8 +109,12 @@ class DemoSharedPreferences(private val context: Context) { if (logsJSONString.isNullOrEmpty()) { return ArrayList() } - val gson = Gson() val type = object : TypeToken>() {}.type return gson.fromJson(logsJSONString, type) ?: ArrayList() } + + companion object { + private const val PREF_KEY_APP_SETTINGS = "app_settings_json" + private const val PREF_KEY_MODULE_SETTINGS = "module_settings_json" + } } diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/LogsActivity.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/LogsActivity.kt index 4c6b75aa9d..de76756c8e 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/LogsActivity.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/LogsActivity.kt @@ -10,7 +10,6 @@ package com.example.executorchllamademo import android.os.Build import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.isSystemInDarkTheme @@ -23,7 +22,6 @@ import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat import com.example.executorchllamademo.ui.screens.LogsScreen import com.example.executorchllamademo.ui.theme.LlamaDemoTheme -import com.google.gson.Gson class LogsActivity : ComponentActivity() { @@ -56,15 +54,7 @@ class LogsActivity : ComponentActivity() { private fun loadAppearanceMode() { val prefs = DemoSharedPreferences(this) - val settingsJson = prefs.getSettings() - if (settingsJson.isNotEmpty()) { - try { - val settings = Gson().fromJson(settingsJson, SettingsFields::class.java) - appearanceMode = settings.appearanceMode - } catch (e: Exception) { - Log.e("LogsActivity", "Error loading appearance mode", e) - } - } + appearanceMode = prefs.getAppSettings().appearanceMode } override fun onResume() { diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/MainActivity.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/MainActivity.kt index e2f3747d72..7aa68d3c92 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/MainActivity.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/MainActivity.kt @@ -37,7 +37,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.example.executorchllamademo.ui.screens.ChatScreen import com.example.executorchllamademo.ui.theme.LlamaDemoTheme import com.example.executorchllamademo.ui.viewmodel.ChatViewModel -import com.google.gson.Gson class MainActivity : ComponentActivity() { @@ -179,15 +178,7 @@ class MainActivity : ComponentActivity() { private fun loadAppearanceMode() { val prefs = DemoSharedPreferences(this) - val settingsJson = prefs.getSettings() - if (settingsJson.isNotEmpty()) { - try { - val settings = Gson().fromJson(settingsJson, SettingsFields::class.java) - appearanceMode = settings.appearanceMode - } catch (e: Exception) { - Log.e("MainActivity", "Error loading appearance mode", e) - } - } + appearanceMode = prefs.getAppSettings().appearanceMode } override fun onResume() { 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 new file mode 100644 index 0000000000..34486ed712 --- /dev/null +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ModuleSettings.kt @@ -0,0 +1,45 @@ +/* + * 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 + +/** + * Holds module-specific settings for the current model/tokenizer configuration. + */ +data class ModuleSettings( + val modelFilePath: String = "", + val tokenizerFilePath: String = "", + val dataPath: String = "", + val temperature: Double = DEFAULT_TEMPERATURE, + val systemPrompt: String = "", + val userPrompt: String = PromptFormat.getUserPromptTemplate(DEFAULT_MODEL), + val modelType: ModelType = DEFAULT_MODEL, + val backendType: BackendType = DEFAULT_BACKEND, + val isClearChatHistory: Boolean = false, + val isLoadModel: Boolean = false +) { + fun getFormattedSystemPrompt(): String { + return PromptFormat.getSystemPromptTemplate(modelType) + .replace(PromptFormat.SYSTEM_PLACEHOLDER, systemPrompt) + } + + fun getFormattedUserPrompt(prompt: String, thinkingMode: Boolean): String { + return userPrompt + .replace(PromptFormat.USER_PLACEHOLDER, prompt) + .replace( + PromptFormat.THINKING_MODE_PLACEHOLDER, + PromptFormat.getThinkingModeToken(modelType, thinkingMode) + ) + } + + companion object { + const val DEFAULT_TEMPERATURE = 0.0 + val DEFAULT_MODEL = ModelType.LLAMA_3 + val DEFAULT_BACKEND = BackendType.XNNPACK + } +} diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/SettingsActivity.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/SettingsActivity.kt index 96c3fd14b3..0d3f682745 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/SettingsActivity.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/SettingsActivity.kt @@ -10,7 +10,6 @@ package com.example.executorchllamademo import android.os.Build import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.isSystemInDarkTheme @@ -25,7 +24,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.example.executorchllamademo.ui.screens.SettingsScreen import com.example.executorchllamademo.ui.theme.LlamaDemoTheme import com.example.executorchllamademo.ui.viewmodel.SettingsViewModel -import com.google.gson.Gson import java.io.File class SettingsActivity : ComponentActivity() { @@ -72,15 +70,7 @@ class SettingsActivity : ComponentActivity() { private fun loadAppearanceMode() { val prefs = DemoSharedPreferences(this) - val settingsJson = prefs.getSettings() - if (settingsJson.isNotEmpty()) { - try { - val settings = Gson().fromJson(settingsJson, SettingsFields::class.java) - appearanceMode = settings.appearanceMode - } catch (e: Exception) { - Log.e("SettingsActivity", "Error loading appearance mode", e) - } - } + appearanceMode = prefs.getAppSettings().appearanceMode } override fun onResume() { diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/SettingsFields.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/SettingsFields.kt deleted file mode 100644 index 07b3dabaca..0000000000 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/SettingsFields.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * 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 - -/** - * Holds all settings fields for the application. - * - * Note: This is not a data class because it has mutable state and custom copy/save methods. - */ -class SettingsFields( - var modelFilePath: String = "", - var tokenizerFilePath: String = "", - var dataPath: String = "", - var temperature: Double = DEFAULT_TEMPERATURE, - var systemPrompt: String = "", - var userPrompt: String = PromptFormat.getUserPromptTemplate(DEFAULT_MODEL), - isClearChatHistory: Boolean = false, - isLoadModel: Boolean = false, - var modelType: ModelType = DEFAULT_MODEL, - var backendType: BackendType = DEFAULT_BACKEND, - var appearanceMode: AppearanceMode = DEFAULT_APPEARANCE -) { - // Use backing fields with explicit getters to maintain Java compatibility - // Java expects getIsClearChatHistory() and getIsLoadModel() - @get:JvmName("getIsClearChatHistory") - var isClearChatHistory: Boolean = isClearChatHistory - - @get:JvmName("getIsLoadModel") - var isLoadModel: Boolean = isLoadModel - /** - * Copy constructor - */ - constructor(other: SettingsFields) : this( - modelFilePath = other.modelFilePath, - tokenizerFilePath = other.tokenizerFilePath, - dataPath = other.dataPath, - temperature = other.temperature, - systemPrompt = other.systemPrompt, - userPrompt = other.userPrompt, - isClearChatHistory = other.isClearChatHistory, - isLoadModel = other.isLoadModel, - modelType = other.modelType, - backendType = other.backendType, - appearanceMode = other.appearanceMode - ) - - fun getFormattedSystemPrompt(): String { - return PromptFormat.getSystemPromptTemplate(modelType) - .replace(PromptFormat.SYSTEM_PLACEHOLDER, systemPrompt) - } - - fun getFormattedUserPrompt(prompt: String, thinkingMode: Boolean): String { - return userPrompt - .replace(PromptFormat.USER_PLACEHOLDER, prompt) - .replace( - PromptFormat.THINKING_MODE_PLACEHOLDER, - PromptFormat.getThinkingModeToken(modelType, thinkingMode) - ) - } - - // Save methods for backward compatibility with existing Java code - fun saveModelPath(modelFilePath: String) { - this.modelFilePath = modelFilePath - } - - fun saveTokenizerPath(tokenizerFilePath: String) { - this.tokenizerFilePath = tokenizerFilePath - } - - fun saveModelType(modelType: ModelType) { - this.modelType = modelType - } - - fun saveBackendType(backendType: BackendType) { - this.backendType = backendType - } - - fun saveParameters(temperature: Double) { - this.temperature = temperature - } - - fun savePrompts(systemPrompt: String, userPrompt: String) { - this.systemPrompt = systemPrompt - this.userPrompt = userPrompt - } - - fun saveIsClearChatHistory(needToClear: Boolean) { - this.isClearChatHistory = needToClear - } - - fun saveLoadModelAction(shouldLoadModel: Boolean) { - this.isLoadModel = shouldLoadModel - } - - fun saveDataPath(dataPath: String) { - this.dataPath = dataPath - } - - fun saveAppearanceMode(appearanceMode: AppearanceMode) { - this.appearanceMode = appearanceMode - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || javaClass != other.javaClass) return false - other as SettingsFields - return temperature.compareTo(other.temperature) == 0 && - isClearChatHistory == other.isClearChatHistory && - isLoadModel == other.isLoadModel && - modelFilePath == other.modelFilePath && - tokenizerFilePath == other.tokenizerFilePath && - dataPath == other.dataPath && - systemPrompt == other.systemPrompt && - userPrompt == other.userPrompt && - modelType == other.modelType && - backendType == other.backendType && - appearanceMode == other.appearanceMode - } - - override fun hashCode(): Int { - return listOf( - modelFilePath, - tokenizerFilePath, - dataPath, - temperature, - systemPrompt, - userPrompt, - isClearChatHistory, - isLoadModel, - modelType, - backendType, - appearanceMode - ).hashCode() - } - - companion object { - const val DEFAULT_TEMPERATURE = 0.0 - private val DEFAULT_MODEL = ModelType.LLAMA_3 - private val DEFAULT_BACKEND = BackendType.XNNPACK - private val DEFAULT_APPEARANCE = AppearanceMode.SYSTEM - } -} diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/SettingsScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/SettingsScreen.kt index 1c11f3c025..bd4e816921 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/SettingsScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/SettingsScreen.kt @@ -117,7 +117,7 @@ fun SettingsScreen( // Appearance selector (first) SettingsRow( label = "Appearance", - value = viewModel.settingsFields.appearanceMode.displayName, + value = viewModel.appSettings.appearanceMode.displayName, onClick = { viewModel.showAppearanceDialog = true } ) @@ -126,7 +126,7 @@ fun SettingsScreen( // Backend selector SettingsRow( label = "Backend", - value = viewModel.settingsFields.backendType.toString(), + value = viewModel.moduleSettings.backendType.toString(), onClick = { viewModel.showBackendDialog = true } ) @@ -137,7 +137,7 @@ fun SettingsScreen( // Model selector SettingsRow( label = "Model", - value = viewModel.getFilenameFromPath(viewModel.settingsFields.modelFilePath) + value = viewModel.getFilenameFromPath(viewModel.moduleSettings.modelFilePath) .ifEmpty { "no model selected" }, onClick = { viewModel.refreshFileLists() @@ -150,7 +150,7 @@ fun SettingsScreen( // Tokenizer selector SettingsRow( label = "Tokenizer", - value = viewModel.getFilenameFromPath(viewModel.settingsFields.tokenizerFilePath) + value = viewModel.getFilenameFromPath(viewModel.moduleSettings.tokenizerFilePath) .ifEmpty { "no tokenizer selected" }, onClick = { viewModel.refreshFileLists() @@ -163,7 +163,7 @@ fun SettingsScreen( // Data path selector SettingsRow( label = "Data Path", - value = viewModel.getFilenameFromPath(viewModel.settingsFields.dataPath) + value = viewModel.getFilenameFromPath(viewModel.moduleSettings.dataPath) .ifEmpty { "no data path selected" }, onClick = { viewModel.refreshFileLists() @@ -177,7 +177,7 @@ fun SettingsScreen( // Model type selector SettingsRow( label = "Model Type", - value = viewModel.settingsFields.modelType.toString(), + value = viewModel.moduleSettings.modelType.toString(), onClick = { viewModel.showModelTypeDialog = true } ) @@ -210,8 +210,8 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(8.dp)) - var temperatureText by remember(viewModel.settingsFields.temperature) { - mutableStateOf(String.format("%.2f", viewModel.settingsFields.temperature)) + var temperatureText by remember(viewModel.moduleSettings.temperature) { + mutableStateOf(String.format("%.2f", viewModel.moduleSettings.temperature)) } Row( @@ -228,7 +228,7 @@ fun SettingsScreen( // Slider Slider( - value = viewModel.settingsFields.temperature.toFloat().coerceIn(0f, 2f), + value = viewModel.moduleSettings.temperature.toFloat().coerceIn(0f, 2f), onValueChange = { newValue -> val rounded = (newValue * 100).toInt() / 100.0 temperatureText = String.format("%.2f", rounded) @@ -306,7 +306,7 @@ fun SettingsScreen( // System Prompt PromptSection( title = "System Prompt", - value = viewModel.settingsFields.systemPrompt, + value = viewModel.moduleSettings.systemPrompt, onValueChange = { viewModel.updateSystemPrompt(it) }, onReset = { viewModel.showResetSystemPromptDialog = true } ) @@ -316,7 +316,7 @@ fun SettingsScreen( // User Prompt Format PromptSection( title = "Prompt Format", - value = viewModel.settingsFields.userPrompt, + value = viewModel.moduleSettings.userPrompt, onValueChange = { viewModel.updateUserPrompt(it) }, onReset = { viewModel.showResetUserPromptDialog = true } ) 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 73bf462679..5d895fffaf 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 @@ -27,7 +27,7 @@ import com.example.executorchllamademo.MessageType import com.example.executorchllamademo.ModelType import com.example.executorchllamademo.ModelUtils import com.example.executorchllamademo.PromptFormat -import com.example.executorchllamademo.SettingsFields +import com.example.executorchllamademo.ModuleSettings import com.google.gson.Gson import com.google.gson.reflect.TypeToken import org.json.JSONException @@ -72,7 +72,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L private var module: LlmModule? = null private var resultMessage: Message? = null private val demoSharedPreferences = DemoSharedPreferences(application) - private var currentSettingsFields = SettingsFields() + private var currentSettingsFields = ModuleSettings() private var promptID = 0 private var sawStartHeaderId = false private var audioFileToPrefill: String? = null @@ -105,45 +105,40 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L private val systemPromptMessage = "To get started, select your desired model and tokenizer from the top right corner" fun checkAndLoadSettings() { - val gson = Gson() - val settingsFieldsJSON = demoSharedPreferences.getSettings() - if (settingsFieldsJSON.isNotEmpty()) { - val updatedSettingsFields = gson.fromJson(settingsFieldsJSON, SettingsFields::class.java) - if (updatedSettingsFields == null) { - addSystemMessage(systemPromptMessage) - return - } - val isUpdated = currentSettingsFields != updatedSettingsFields - val isLoadModel = updatedSettingsFields.isLoadModel - if (isUpdated) { - checkForClearChatHistory(updatedSettingsFields) - currentSettingsFields = SettingsFields(updatedSettingsFields) - // Update media capabilities after settings are updated - setBackendMode(updatedSettingsFields.backendType) - - if (isLoadModel) { - loadLocalModelAndParameters( - updatedSettingsFields.modelFilePath, - updatedSettingsFields.tokenizerFilePath, - updatedSettingsFields.dataPath, - updatedSettingsFields.temperature.toFloat() - ) - updatedSettingsFields.saveLoadModelAction(false) - demoSharedPreferences.addSettings(updatedSettingsFields) - } else if (module == null) { - addSystemMessage(systemPromptMessage) - } + val updatedSettingsFields = demoSharedPreferences.getModuleSettings() + val isUpdated = currentSettingsFields != updatedSettingsFields + val isLoadModel = updatedSettingsFields.isLoadModel + 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() + ) + // 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) + demoSharedPreferences.saveModuleSettings(settingsWithLoadFlagCleared) + currentSettingsFields = settingsWithLoadFlagCleared } else { - // Settings unchanged, but still update media capabilities for current settings - setBackendMode(updatedSettingsFields.backendType) - val modelPath = updatedSettingsFields.modelFilePath - val tokenizerPath = updatedSettingsFields.tokenizerFilePath - if (modelPath.isEmpty() || tokenizerPath.isEmpty()) { + currentSettingsFields = updatedSettingsFields.copy() + if (module == null) { addSystemMessage(systemPromptMessage) } } - } else if (module == null) { - addSystemMessage(systemPromptMessage) + } else { + // Settings unchanged, but still update media capabilities for current settings + setBackendMode(updatedSettingsFields.backendType) + val modelPath = updatedSettingsFields.modelFilePath + val tokenizerPath = updatedSettingsFields.tokenizerFilePath + if (modelPath.isEmpty() || tokenizerPath.isEmpty()) { + addSystemMessage(systemPromptMessage) + } } } @@ -177,12 +172,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L } } - private fun checkForClearChatHistory(updatedSettingsFields: SettingsFields) { + private fun checkForClearChatHistory(updatedSettingsFields: ModuleSettings) { if (updatedSettingsFields.isClearChatHistory) { _messages.clear() demoSharedPreferences.removeExistingMessages() - updatedSettingsFields.saveIsClearChatHistory(false) - demoSharedPreferences.addSettings(updatedSettingsFields) + demoSharedPreferences.saveModuleSettings(updatedSettingsFields.copy(isClearChatHistory = false)) module?.resetContext() } } @@ -239,6 +233,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L var loadDuration = System.currentTimeMillis() - runStartTime var modelInfo: String + var loadSuccess = false try { module?.load() val pteName = modelPath.substringAfterLast('/') @@ -251,8 +246,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L module?.prefillPrompt(PromptFormat.getLlavaPresetPrompt()) ETLogging.getInstance().log("Llava completes prefill prompt") } + loadSuccess = true } catch (e: ExecutorchRuntimeException) { - modelInfo = "${e.message}\n" + modelInfo = "Model load failure: ${e.message}" loadDuration = 0 modelLoadError = modelInfo showModelLoadErrorDialog = true @@ -267,7 +263,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L "Model loaded time: $loadDuration ms" ETLogging.getInstance().log("Load complete. $modelLoggingInfo") - isModelReady = true + isModelReady = loadSuccess _messages.remove(modelLoadingMessage) _messages.add(modelLoadedMessage) } @@ -440,6 +436,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L } fun stopGeneration() { + Log.i("ChatViewModel", "stopGeneration called") module?.stop() } diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/SettingsViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/SettingsViewModel.kt index ce0dd6d137..0b321beeea 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/SettingsViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/SettingsViewModel.kt @@ -14,18 +14,20 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import com.example.executorchllamademo.AppearanceMode +import com.example.executorchllamademo.AppSettings import com.example.executorchllamademo.BackendType import com.example.executorchllamademo.DemoSharedPreferences -import com.example.executorchllamademo.ETLogging import com.example.executorchllamademo.ModelType +import com.example.executorchllamademo.ModuleSettings import com.example.executorchllamademo.PromptFormat import com.example.executorchllamademo.SettingsActivity -import com.example.executorchllamademo.SettingsFields -import com.google.gson.Gson class SettingsViewModel : ViewModel() { - var settingsFields by mutableStateOf(SettingsFields()) + var moduleSettings by mutableStateOf(ModuleSettings()) + private set + + var appSettings by mutableStateOf(AppSettings()) private set // Dialog states @@ -57,15 +59,15 @@ class SettingsViewModel : ViewModel() { } private fun loadSettings() { - val gson = Gson() - val settingsFieldsJSON = demoSharedPreferences?.getSettings() ?: "" - if (settingsFieldsJSON.isNotEmpty()) { - settingsFields = gson.fromJson(settingsFieldsJSON, SettingsFields::class.java) + demoSharedPreferences?.let { prefs -> + moduleSettings = prefs.getModuleSettings() + appSettings = prefs.getAppSettings() } } fun saveSettings() { - demoSharedPreferences?.addSettings(settingsFields) + demoSharedPreferences?.saveModuleSettings(moduleSettings) + demoSharedPreferences?.saveAppSettings(appSettings) } fun refreshFileLists() { @@ -76,107 +78,90 @@ class SettingsViewModel : ViewModel() { // Backend selection fun selectBackend(backendType: BackendType) { - val newSettings = SettingsFields(settingsFields) - newSettings.saveBackendType(backendType) - applyBackendDefaults(newSettings) - settingsFields = newSettings - } - - private fun applyBackendDefaults(settings: SettingsFields) { - if (settings.backendType == BackendType.MEDIATEK) { - if (settings.modelFilePath.isEmpty()) { - settings.saveModelPath("/in/mtk/llama/runner") - } - if (settings.tokenizerFilePath.isEmpty()) { - settings.saveTokenizerPath("/in/mtk/llama/runner") - } + var newSettings = moduleSettings.copy(backendType = backendType) + newSettings = applyBackendDefaults(newSettings) + moduleSettings = newSettings + } + + private fun applyBackendDefaults(settings: ModuleSettings): ModuleSettings { + return if (settings.backendType == BackendType.MEDIATEK) { + settings.copy( + modelFilePath = settings.modelFilePath.ifEmpty { "/in/mtk/llama/runner" }, + tokenizerFilePath = settings.tokenizerFilePath.ifEmpty { "/in/mtk/llama/runner" } + ) + } else { + settings } } // Model selection fun selectModel(modelPath: String) { - val newSettings = SettingsFields(settingsFields) - newSettings.saveModelPath(modelPath) - autoSelectModelType(newSettings, modelPath) - settingsFields = newSettings + var newSettings = moduleSettings.copy(modelFilePath = modelPath) + newSettings = autoSelectModelType(newSettings, modelPath) + moduleSettings = newSettings } - private fun autoSelectModelType(settings: SettingsFields, filePath: String) { + private fun autoSelectModelType(settings: ModuleSettings, filePath: String): ModuleSettings { val detectedType = ModelType.fromFilePath(filePath) - if (detectedType != null) { - settings.saveModelType(detectedType) - settings.savePrompts( - settings.systemPrompt, - PromptFormat.getUserPromptTemplate(detectedType) + return if (detectedType != null) { + settings.copy( + modelType = detectedType, + userPrompt = PromptFormat.getUserPromptTemplate(detectedType) ) + } else { + settings } } // Tokenizer selection fun selectTokenizer(tokenizerPath: String) { - val newSettings = SettingsFields(settingsFields) - newSettings.saveTokenizerPath(tokenizerPath) - settingsFields = newSettings + moduleSettings = moduleSettings.copy(tokenizerFilePath = tokenizerPath) } // Data path selection fun selectDataPath(dataPath: String) { - val newSettings = SettingsFields(settingsFields) - newSettings.saveDataPath(dataPath) - settingsFields = newSettings + moduleSettings = moduleSettings.copy(dataPath = dataPath) } // Model type selection fun selectModelType(modelType: ModelType) { - val newSettings = SettingsFields(settingsFields) - newSettings.saveModelType(modelType) - newSettings.savePrompts( - newSettings.systemPrompt, - PromptFormat.getUserPromptTemplate(modelType) + moduleSettings = moduleSettings.copy( + modelType = modelType, + userPrompt = PromptFormat.getUserPromptTemplate(modelType) ) - settingsFields = newSettings } // Temperature fun updateTemperature(temperature: Double) { - val newSettings = SettingsFields(settingsFields) - newSettings.saveParameters(temperature) - newSettings.saveLoadModelAction(true) - settingsFields = newSettings + moduleSettings = moduleSettings.copy( + temperature = temperature, + isLoadModel = true + ) saveSettings() } // System prompt fun updateSystemPrompt(prompt: String) { - val newSettings = SettingsFields(settingsFields) - newSettings.savePrompts(prompt, newSettings.userPrompt) - settingsFields = newSettings + moduleSettings = moduleSettings.copy(systemPrompt = prompt) } fun resetSystemPrompt() { - val newSettings = SettingsFields(settingsFields) - newSettings.savePrompts(PromptFormat.DEFAULT_SYSTEM_PROMPT, newSettings.userPrompt) - settingsFields = newSettings + moduleSettings = moduleSettings.copy(systemPrompt = PromptFormat.DEFAULT_SYSTEM_PROMPT) } // User prompt fun updateUserPrompt(prompt: String) { if (isValidUserPrompt(prompt)) { - val newSettings = SettingsFields(settingsFields) - newSettings.savePrompts(newSettings.systemPrompt, prompt) - settingsFields = newSettings + moduleSettings = moduleSettings.copy(userPrompt = prompt) } else { showInvalidPromptDialog = true } } fun resetUserPrompt() { - val newSettings = SettingsFields(settingsFields) - newSettings.savePrompts( - newSettings.systemPrompt, - PromptFormat.getUserPromptTemplate(newSettings.modelType) + moduleSettings = moduleSettings.copy( + userPrompt = PromptFormat.getUserPromptTemplate(moduleSettings.modelType) ) - settingsFields = newSettings } private fun isValidUserPrompt(userPrompt: String): Boolean { @@ -186,37 +171,31 @@ class SettingsViewModel : ViewModel() { // Load model action fun confirmLoadModel() { saveSettings() - val newSettings = SettingsFields(settingsFields) - newSettings.saveLoadModelAction(true) - settingsFields = newSettings + moduleSettings = moduleSettings.copy(isLoadModel = true) } // Clear chat fun confirmClearChat() { - val newSettings = SettingsFields(settingsFields) - newSettings.saveIsClearChatHistory(true) - settingsFields = newSettings + moduleSettings = moduleSettings.copy(isClearChatHistory = true) saveSettings() } // Validation fun isLoadModelEnabled(): Boolean { - return settingsFields.modelFilePath.isNotEmpty() && settingsFields.tokenizerFilePath.isNotEmpty() + return moduleSettings.modelFilePath.isNotEmpty() && moduleSettings.tokenizerFilePath.isNotEmpty() } fun isMediaTekMode(): Boolean { - return settingsFields.backendType == BackendType.MEDIATEK + return moduleSettings.backendType == BackendType.MEDIATEK } fun getFilenameFromPath(path: String): String { return if (path.isEmpty()) "" else path.substringAfterLast('/') } - // Appearance mode selection + // Appearance mode selection (app-wide setting) fun selectAppearanceMode(mode: AppearanceMode) { - val newSettings = SettingsFields(settingsFields) - newSettings.saveAppearanceMode(mode) - settingsFields = newSettings - saveSettings() + appSettings = appSettings.copy(appearanceMode = mode) + demoSharedPreferences?.saveAppSettings(appSettings) } }