Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import com.google.android.material.materialswitch.MaterialSwitch
import com.itsaky.androidide.R
import com.itsaky.androidide.activities.editor.BaseEditorActivity
import com.itsaky.androidide.agent.repository.AiBackend
import com.itsaky.androidide.agent.repository.Util.getCurrentBackend
import com.itsaky.androidide.agent.viewmodel.AiSettingsViewModel
Expand All @@ -28,6 +30,7 @@ import com.itsaky.androidide.agent.viewmodel.ModelLoadingState
import com.itsaky.androidide.databinding.FragmentAiSettingsBinding
import com.itsaky.androidide.utils.flashInfo
import com.itsaky.androidide.utils.getFileName
import com.itsaky.androidide.viewmodel.BottomSheetViewModel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
Expand All @@ -50,6 +53,13 @@ class AiSettingsFragment : Fragment(R.layout.fragment_ai_settings) {
val uriString = it.toString()
viewModel.loadModelFromUri(uriString, requireContext())
flashInfo("Attempting to load selected model...")

view?.postDelayed({
(activity as? BaseEditorActivity)?.bottomSheetViewModel?.setSheetState(
sheetState = BottomSheetBehavior.STATE_EXPANDED,
currentTab = BottomSheetViewModel.TAB_AGENT
)
}, 100)
}
}

Expand Down Expand Up @@ -111,7 +121,7 @@ class AiSettingsFragment : Fragment(R.layout.fragment_ai_settings) {
val browseButton = view.findViewById<Button>(R.id.btn_browse_model)
val loadSavedButton = view.findViewById<Button>(R.id.loadSavedButton)
val modelStatusTextView = view.findViewById<TextView>(R.id.model_status_text_view)
val engineStatusTextView = view.findViewById<TextView>(R.id.engine_status_text) // <-- NEW: Get reference to the new TextView
val engineStatusTextView = view.findViewById<TextView>(R.id.engine_status_text)
val simplePromptSwitch = view.findViewById<MaterialSwitch>(R.id.switch_simple_local_prompt)
val shaInput = view.findViewById<TextInputEditText>(R.id.local_model_sha_input)

Expand Down Expand Up @@ -204,6 +214,13 @@ class AiSettingsFragment : Fragment(R.layout.fragment_ai_settings) {
}
if (hasPermission) {
viewModel.loadModelFromUri(savedUri, requireContext())

view?.postDelayed({
(activity as? BaseEditorActivity)?.bottomSheetViewModel?.setSheetState(
sheetState = BottomSheetBehavior.STATE_EXPANDED,
currentTab = BottomSheetViewModel.TAB_AGENT
)
}, 100)
} else {
requireActivity().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
remove(SAVED_MODEL_URI_KEY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,29 @@ class LlmInferenceEngine(
private const val CONTEXT_SIZE_MID_MEM = 2048
private const val CONTEXT_SIZE_HIGH_MEM = 3072
private const val CONTEXT_SIZE_MAX = 4096

private const val EXT_ONNX = ".onnx"
private const val EXT_PT = ".pt"
private const val EXT_PTH = ".pth"
private const val EXT_BIN = ".bin"
private const val EXT_SAFETENSORS = ".safetensors"
private const val EXT_PB = ".pb"
private const val EXT_TFLITE = ".tflite"
private const val EXT_GGML = ".ggml"
private const val EXT_GGUF = ".gguf"

private const val KEYWORD_TENSORFLOW = "tensorflow"
private const val KEYWORD_ALL_MINI = "all-mini"
private const val KEYWORD_ALL_MPNET = "all-mpnet"
private const val KEYWORD_E5 = "e5-"
private const val KEYWORD_EMBED = "embed"
private const val KEYWORD_LLAMA = "llama"
private const val KEYWORD_H2O = "h2o"
private const val KEYWORD_DANUBE = "danube"
private const val KEYWORD_QWEN = "qwen"
private const val KEYWORD_GEMMA3 = "gemma3"
private const val KEYWORD_GEMMA_3 = "gemma-3"
private const val KEYWORD_GEMMA = "gemma"
}

/**
Expand Down Expand Up @@ -291,9 +314,12 @@ class LlmInferenceEngine(
modelUriString: String,
expectedSha256: String?
): Boolean {
val modelUri = modelUriString.toUri()
val displayName = resolveModelDisplayName(context, modelUri)

return try {
val modelUri = modelUriString.toUri()
val displayName = resolveModelDisplayName(context, modelUri)
validateModelFormat(displayName)

val destinationFile = File(context.cacheDir, "local_model.gguf")

if (!copyModelToCache(context, modelUri, destinationFile)) {
Expand All @@ -313,6 +339,23 @@ class LlmInferenceEngine(
currentModelFamily = detectModelFamily(displayName)
log.info("Successfully loaded local model: {}", loadedModelName)
true
} catch (e: IllegalStateException) {
if (e.message?.contains("embedding model") == true) {
log.error("Cannot use embedding model for chat: {}", displayName, e)
Comment on lines +343 to +344

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make embedding-error detection case-insensitive.

The current message check can miss variants like Embedding model, which bypasses the intended IllegalArgumentException mapping and user guidance.

Suggested fix
-            if (e.message?.contains("embedding model") == true) {
+            if (e.message?.contains("embedding model", ignoreCase = true) == true) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (e.message?.contains("embedding model") == true) {
log.error("Cannot use embedding model for chat: {}", displayName, e)
if (e.message?.contains("embedding model", ignoreCase = true) == true) {
log.error("Cannot use embedding model for chat: {}", displayName, e)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/itsaky/androidide/agent/repository/LlmInferenceEngine.kt`
around lines 343 - 344, The error message check in LlmInferenceEngine.kt at the
if statement checking for "embedding model" is case-sensitive and will miss
error messages with different casing like "Embedding model". Make the message
comparison case-insensitive by converting the error message to lowercase before
performing the contains check, or by using a case-insensitive comparison method
that ignores the case parameter. This ensures that all variations of the
embedding model error message are properly detected and the user receives
appropriate guidance.

throw IllegalArgumentException(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uncaught exception reintroduces the crash this PR fixes (on other load paths).

After this change initModelFromFile now throws IllegalArgumentException — both for the embedding-model path here and for validateModelFormat rejections (rethrown at line 358). Previously it caught everything and returned false.

Only AiSettingsViewModel was updated to catch it. The other three callers still treat the result as a plain Boolean and have no try/catch, so the exception escapes into their coroutines:

  • agent/.../ChatViewModel.kt:466 (autoLoadLocalModelIfNeeded)
  • app/.../ChatViewModel.kt:161 (getOrCreateRepository)
  • LocalLlmRepositoryImpl.kt:73 (loadModel)

Scenario: a saved local-model path that resolves to an embedding/unsupported model (e.g. saved by a pre-fix build, or a .gguf embedding model) → on startup/chat-open the auto-load throws an uncaught IllegalArgumentException → app crash. That is exactly the ADFA-4388 crash class, just relocated to the auto-load path.

Fix: either wrap those three call sites in try/catch and surface the message, or have initModelFromFile return a sealed result (Loaded/Rejected(reason)/Failed) instead of throwing, so the contract is uniform for all callers.

"The selected model '$displayName' is an embedding model designed for semantic " +
"search and similarity tasks. It cannot be used for chat or text generation.\n\n" +
"Please select a chat/instruct model instead (e.g., models with 'chat', 'instruct', " +
"'conversational' in their name).", e
)
} else {
log.error("Failed to load model", e)
throw e
}
} catch (e: IllegalArgumentException) {
log.error("Model validation failed: {}", displayName, e)
resetLoadedModelState()
throw e
} catch (e: Exception) {
log.error("Failed to initialize or load model from file", e)
resetLoadedModelState()
Expand Down Expand Up @@ -458,14 +501,92 @@ class LlmInferenceEngine(
}
}

/**
* Validates that the model file format is supported.
* This app uses llama.cpp which only supports GGUF format.
*
* @throws IllegalArgumentException if the model format is not supported
*/
private fun validateModelFormat(filename: String) {
val lowerName = filename.lowercase()

when {
lowerName.endsWith(EXT_ONNX) -> {
throw IllegalArgumentException(
"ONNX models ($EXT_ONNX) are not supported.\n\n" +
"This app uses llama.cpp which only supports GGUF format ($EXT_GGUF).\n\n" +
"To use this model:\n" +
"1. Convert it to GGUF format using llama.cpp conversion tools\n" +
"2. Or download a pre-converted GGUF version from Hugging Face"
)
}
lowerName.endsWith(EXT_PT) || lowerName.endsWith(EXT_PTH) || lowerName.endsWith(EXT_BIN) -> {
throw IllegalArgumentException(
"PyTorch models ($EXT_PT, $EXT_PTH, $EXT_BIN) are not supported.\n\n" +
"This app uses llama.cpp which only supports GGUF format ($EXT_GGUF).\n\n" +
"To use this model:\n" +
"1. Convert it to GGUF format using convert_hf_to_gguf.py\n" +
"2. Or download a pre-converted GGUF version from Hugging Face"
)
}
lowerName.endsWith(EXT_SAFETENSORS) -> {
throw IllegalArgumentException(
"SafeTensors models ($EXT_SAFETENSORS) are not directly supported.\n\n" +
"This app uses llama.cpp which only supports GGUF format ($EXT_GGUF).\n\n" +
"To use this model:\n" +
"1. Convert it to GGUF format using convert_hf_to_gguf.py\n" +
"2. Or download a pre-converted GGUF version from Hugging Face"
)
}
lowerName.endsWith(EXT_PB) || lowerName.contains(KEYWORD_TENSORFLOW) -> {
throw IllegalArgumentException(
"TensorFlow models ($EXT_PB) are not supported.\n\n" +
"This app uses llama.cpp which only supports GGUF format ($EXT_GGUF).\n\n" +
"To use this model:\n" +
"1. Convert it to GGUF format using appropriate conversion tools\n" +
"2. Or download a pre-converted GGUF version from Hugging Face"
)
}
lowerName.endsWith(EXT_TFLITE) -> {
throw IllegalArgumentException(
"TensorFlow Lite models ($EXT_TFLITE) are not supported.\n\n" +
"This app uses llama.cpp which only supports GGUF format ($EXT_GGUF).\n\n" +
"Please select a GGUF format model."
)
}
lowerName.endsWith(EXT_GGML) -> {
throw IllegalArgumentException(
"GGML models ($EXT_GGML) are deprecated.\n\n" +
"This app uses the newer GGUF format ($EXT_GGUF).\n\n" +
"To use this model:\n" +
"1. Convert it to GGUF using convert_llama_ggml_to_gguf.py\n" +
"2. Or download a GGUF version from Hugging Face"
)
}
!lowerName.endsWith(EXT_GGUF) -> {
log.warn("Model file '{}' doesn't have $EXT_GGUF extension. May fail to load.", filename)
}
}

if (lowerName.contains(KEYWORD_ALL_MINI) ||
lowerName.contains(KEYWORD_ALL_MPNET) ||
lowerName.contains(KEYWORD_E5) ||
(lowerName.contains(KEYWORD_EMBED) && !lowerName.contains(KEYWORD_LLAMA))) {
log.warn(
"Model '{}' appears to be an embedding model based on filename. " +
"This may not work for chat. Will validate during load.", filename
)
}
}

private fun detectModelFamily(path: String): ModelFamily {
val lowerPath = path.lowercase()
return when {
lowerPath.contains("h2o") || lowerPath.contains("danube") -> ModelFamily.H2O
lowerPath.contains("qwen") -> ModelFamily.QWEN
lowerPath.contains("gemma-3") || lowerPath.contains("gemma3") -> ModelFamily.GEMMA3
lowerPath.contains("gemma") -> ModelFamily.GEMMA2
lowerPath.contains("llama") -> ModelFamily.LLAMA3
lowerPath.contains(KEYWORD_H2O) || lowerPath.contains(KEYWORD_DANUBE) -> ModelFamily.H2O
lowerPath.contains(KEYWORD_QWEN) -> ModelFamily.QWEN
lowerPath.contains(KEYWORD_GEMMA_3) || lowerPath.contains(KEYWORD_GEMMA3) -> ModelFamily.GEMMA3
lowerPath.contains(KEYWORD_GEMMA) -> ModelFamily.GEMMA2
lowerPath.contains(KEYWORD_LLAMA) -> ModelFamily.LLAMA3
else -> ModelFamily.UNKNOWN
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,15 @@ class AiSettingsViewModel(application: Application) : AndroidViewModel(applicati
private val llmInferenceEngine: LlmInferenceEngine = LlmInferenceEngineProvider.instance
private var pendingModelUri: String? = null

// --- State LiveData ---
private val _savedModelPath = MutableLiveData<String?>(null)
val savedModelPath: LiveData<String?> get() = _savedModelPath

private val _modelLoadingState = MutableLiveData<ModelLoadingState>()
val modelLoadingState: LiveData<ModelLoadingState> get() = _modelLoadingState

// NEW: LiveData to track if the engine library is ready
private val _engineState = MutableLiveData<EngineState>(EngineState.Uninitialized)
val engineState: LiveData<EngineState> get() = _engineState

// --- Initialization ---
init {
initializeLlmEngine()
checkInitialSavedModel()
Expand Down Expand Up @@ -105,14 +102,24 @@ class AiSettingsViewModel(application: Application) : AndroidViewModel(applicati

viewModelScope.launch {
_modelLoadingState.value = ModelLoadingState.Loading
val expectedHash = getLocalModelSha256()
val success = llmInferenceEngine.initModelFromFile(context, path, expectedHash)
if (success && llmInferenceEngine.loadedModelName != null) {
_modelLoadingState.value = ModelLoadingState.Loaded(llmInferenceEngine.loadedModelName!!)
// Also save the path on successful load
saveLocalModelPath(path)
} else {
_modelLoadingState.value = ModelLoadingState.Error("Failed to load model file.")
try {
val expectedHash = getLocalModelSha256()
val success = llmInferenceEngine.initModelFromFile(context, path, expectedHash)
if (success && llmInferenceEngine.loadedModelName != null) {
_modelLoadingState.value = ModelLoadingState.Loaded(llmInferenceEngine.loadedModelName!!)
// Also save the path on successful load
saveLocalModelPath(path)
} else {
_modelLoadingState.value = ModelLoadingState.Error("Failed to load model file.")
}
} catch (e: IllegalArgumentException) {
// Handle validation errors (embedding models, unsupported formats, etc.)
_modelLoadingState.value = ModelLoadingState.Error(e.message ?: "Model validation failed.")
Log.e("ModelLoad", "Model validation error: ${e.message}", e)
} catch (e: Exception) {
// Handle any other unexpected errors
_modelLoadingState.value = ModelLoadingState.Error("Failed to load model: ${e.message}")
Log.e("ModelLoad", "Unexpected error loading model", e)
}
}
}
Expand All @@ -129,8 +136,6 @@ class AiSettingsViewModel(application: Application) : AndroidViewModel(applicati
_savedModelPath.value = getLocalModelPath()
}

// --- Preference and Key Management (No changes needed here) ---

fun getAvailableBackends(): List<AiBackend> = AiBackend.entries

fun saveBackend(backend: AiBackend) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import java.util.zip.ZipInputStream

object DynamicLibraryLoader {

private const val LLAMA_LIB_VERSION = 5 // Increment this if you update the AAR
private const val LLAMA_LIB_VERSION = 8 // Increment this if you update the AAR
private const val PREFS_NAME = "dynamic_libs"
private const val PREFS_KEY = "llama_lib_version"

Expand Down
Loading
Loading