Skip to content
Merged
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
22 changes: 22 additions & 0 deletions ai-assistant/src/main/assets/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,28 @@ <h2>Choosing a backend</h2>
agent reads are sent to Google over HTTPS.</li>
</ul>

<h2>Will this model fit in memory?</h2>
<p>When you pick a local <code>.gguf</code> file, it is measured against the RAM
free on the device at that moment, before anything is loaded. If it looks too
large you get a warning with the actual figures and two choices:</p>
<ul>
<li><b>Cancel</b> — nothing is saved and nothing is loaded. Whatever model you
had selected before stays in use. Dismissing the warning with Back does the
same thing.</li>
<li><b>Proceed anyway</b> — the model is accepted despite the shortfall. Use
this when you are about to close other apps, for example.</li>
</ul>
<p>The warning quotes two numbers. Memory <b>to load</b> is the weights: these are
memory-mapped, so they need not all fit at once — when they don't, the device pages
them in and out, which is why an oversized model can stall for minutes instead of
failing immediately. Memory <b>to run</b> is the KV cache and compute buffers, which
are ordinary allocations and do have to fit. That is why a model can be reported as
risky rather than impossible: the outcome genuinely depends on how much paging your
device will tolerate.</p>
<p>To fit a large model, close other apps and select it again, or pick a smaller or
more heavily quantized build. A Q4_K_M quantization of a 1–3B model is the most
likely to run comfortably.</p>

<h2>What the agent can do</h2>
<ul>
<li>Read and search files <b>within the current project</b>.</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension, Settings
const val TOOLTIP_TAG_SETTINGS_GEMINI_MODEL = "ai_settings_gemini_model"
const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_settings_get_free_key"

// Tags for the memory pre-flight warning (see MemoryWarningDialogFragment).
const val TOOLTIP_TAG_MEMORY_PROCEED = "agent_memory_warning_proceed"
const val TOOLTIP_TAG_MEMORY_CANCEL = "agent_memory_warning_cancel"

@Volatile
private var pluginContext: PluginContext? = null

Expand Down Expand Up @@ -457,6 +461,39 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension, Settings
models can't generate replies. Larger models are slower and use
more memory; the file is copied into the app's private storage on
first use.</p>
<p>The model is measured against this device's free memory before it
is accepted. If it looks too large you get the figures and a choice
to cancel or continue.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_MEMORY_PROCEED,
summary = "Load this model anyway, accepting that it may fail or slow the device.",
detail = """
<p>The model's weights plus its working memory look larger than the
RAM free right now. Weights are memory-mapped, so a load can still
succeed by paging — which is why the outcome is a risk rather than a
certainty: it may work, fail quickly, or stall for minutes first.</p>
<p>Use this when you know the numbers are wrong for your situation,
for example because you are about to close other apps.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_MEMORY_CANCEL,
summary = "Abandon this model; the previously selected one is left untouched.",
detail = """
<p>Nothing is saved and nothing is loaded, so the model you had
selected before stays in use. This is the safe choice, and also what
happens if you dismiss the warning with Back.</p>
<p>To fit a large model, close other apps and pick it again, or
choose a smaller or more heavily quantized build — a Q4_K_M
quantization of a 1–3B model is the most likely to run.</p>
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
Expand All @@ -31,6 +33,7 @@ import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiBackend
import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiSettingsViewModel
import com.itsaky.androidide.plugins.aiassistant.viewmodel.EngineState
import com.itsaky.androidide.plugins.aiassistant.viewmodel.ModelLoadingState
import com.itsaky.androidide.plugins.aiassistant.viewmodel.ModelMemoryWarning
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
Expand All @@ -42,7 +45,7 @@ import kotlin.math.roundToInt
* chat's own shortcuts. The host mounts it full-screen in PluginScreenActivity, which provides no
* toolbar, so this fragment brings its own app bar and closes by finishing that activity.
*/
class AiSettingsFragment : Fragment() {
class AiSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host {

private lateinit var viewModel: AiSettingsViewModel
private lateinit var settingsToolbar: LinearLayout
Expand Down Expand Up @@ -130,6 +133,57 @@ class AiSettingsFragment : Fragment() {
initializeViews(view)
setupToolbar()
setupBackendSelector()
observeMemoryWarnings()
}

/**
* Puts a "this model may not fit" question to the user. Collected under STARTED so the dialog is
* never shown to a stopped fragment; the event waits in the ViewModel until then.
*/
private fun observeMemoryWarnings() {
dropStaleMemoryWarning()
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.modelMemoryWarnings.collect(::showMemoryWarning)
}
}
}

/**
* Dismiss a warning dialog the framework restored around a question that no longer exists.
* After process death the load that raised it is gone, so every button on it would be a silent
* no-op — better to take it away than to leave the user pressing a dialog that decides nothing.
*/
private fun dropStaleMemoryWarning() {
if (viewModel.hasPendingMemoryWarning) return
val restored = childFragmentManager.findFragmentByTag(MemoryWarningDialogFragment.TAG)
(restored as? MemoryWarningDialogFragment)?.dismissAllowingStateLoss()
}

/**
* Shown as a child fragment, so it survives rotation and can still reach this host. Must stay
* idempotent: an unanswered question is re-published to every new collector by
* [com.itsaky.androidide.plugins.aiassistant.viewmodel.UserConfirmation].
*
* @param warning the model and the figures to put to the user
*/
private fun showMemoryWarning(warning: ModelMemoryWarning) {
if (childFragmentManager.findFragmentByTag(MemoryWarningDialogFragment.TAG) != null) return
MemoryWarningDialogFragment.newInstance(warning)
.show(childFragmentManager, MemoryWarningDialogFragment.TAG)
}

override fun onModelMemoryDecision(proceed: Boolean) {
viewModel.onMemoryWarningDecision(proceed)
// Not requireContext(): onCancel can reach us as the fragment is going away.
val ctx = context ?: return
if (!proceed) {
Toast.makeText(
ctx,
getString(R.string.llm_memory_warning_declined),
Toast.LENGTH_LONG,
).show()
}
}

override fun onResume() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package com.itsaky.androidide.plugins.aiassistant.fragments

import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
import com.itsaky.androidide.plugins.aiassistant.R
import com.itsaky.androidide.plugins.aiassistant.memory.ModelMemoryGate
import com.itsaky.androidide.plugins.aiassistant.util.ByteSize
import com.itsaky.androidide.plugins.aiassistant.viewmodel.ModelMemoryWarning
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
import com.itsaky.androidide.plugins.services.IdeTooltipService

/**
* Warns that a selected model may be too large for this device, offering **Cancel** — the default
* in every sense visible to the user — or **Proceed anyway** (ADFA-1798). Decisions go to [Host],
* resolved per call so they still arrive after the framework recreates this on a rotation.
*/
class MemoryWarningDialogFragment : DialogFragment() {

private val tooltipService: IdeTooltipService? by lazy {
try {
PluginFragmentHelper.getServiceRegistry(AiAssistantPlugin.PLUGIN_ID)
?.get(IdeTooltipService::class.java)
} catch (e: Exception) {
// Tooltip help is optional; long-press simply shows nothing when it's unavailable.
AiAssistantPlugin.getContext()?.logger
?.warn("MemoryWarningDialogFragment: tooltip service unavailable", e)
null
}
}

/**
* Receives this dialog's outcome. Implemented by the fragment that shows the dialog, which must
* be its **parent** fragment (show it with `childFragmentManager`).
*/
interface Host {
/** @param proceed true to load the model anyway, false to abandon the selection. */
fun onModelMemoryDecision(proceed: Boolean)
}

companion object {
const val TAG = "ModelMemoryWarning"

private const val ARG_MODEL_NAME = "model_name"
private const val ARG_LOAD_BYTES = "load_bytes"
private const val ARG_RUN_BYTES = "run_bytes"
private const val ARG_AVAILABLE_BYTES = "available_bytes"
private const val ARG_SEVERITY = "severity"

/**
* Builds the dialog. Everything it needs is in [getArguments], so the framework can recreate
* it after a configuration change while the answer channel stays on the ViewModel.
*
* @param warning the model and the figures to show
*/
fun newInstance(warning: ModelMemoryWarning): MemoryWarningDialogFragment =
MemoryWarningDialogFragment().apply {
arguments = Bundle().apply {
putString(ARG_MODEL_NAME, warning.modelName)
putLong(ARG_LOAD_BYTES, warning.loadBytes)
putLong(ARG_RUN_BYTES, warning.runBytes)
putLong(ARG_AVAILABLE_BYTES, warning.availableBytes)
putString(ARG_SEVERITY, warning.severity.name)
}
}
}

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val args = arguments ?: Bundle.EMPTY
val messageId = when (severity()) {
ModelMemoryGate.Severity.INSUFFICIENT -> R.string.llm_memory_warning_insufficient
ModelMemoryGate.Severity.TIGHT -> R.string.llm_memory_warning_tight
}
val message = getString(
messageId,
args.getString(ARG_MODEL_NAME).orEmpty(),
ByteSize.format(args.getLong(ARG_LOAD_BYTES)),
ByteSize.format(args.getLong(ARG_RUN_BYTES)),
ByteSize.format(args.getLong(ARG_AVAILABLE_BYTES)),
)

val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.llm_memory_warning_title))
.setMessage(message)
// Cancel takes the positive slot: Material emphasizes that button.
.setPositiveButton(getString(R.string.llm_memory_warning_cancel)) { _, _ -> decide(false) }
.setNegativeButton(getString(R.string.llm_memory_warning_proceed)) { _, _ -> decide(true) }
.create()

// Bound after show(): the buttons don't exist before it.
dialog.setOnShowListener {
val cancelButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
wireTooltip(cancelButton, AiAssistantPlugin.TOOLTIP_TAG_MEMORY_CANCEL)
wireTooltip(
dialog.getButton(Dialog.BUTTON_NEGATIVE),
AiAssistantPlugin.TOOLTIP_TAG_MEMORY_PROCEED,
)
// So Enter, D-pad and a screen reader all reach the safe choice first.
cancelButton?.requestFocus()
}

return dialog
}

/**
* Back press and outside tap both mean "don't risk it". Overridden rather than set on the
* builder: [DialogFragment] replaces a builder cancel listener in `prepareDialog`, so the
* awaiting caller would never learn the model was declined.
*
* @param dialog the dialog that was cancelled
*/
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
decide(false)
}

/** Falls back to the milder wording, so a bad argument can't overstate the risk. */
private fun severity(): ModelMemoryGate.Severity = try {
ModelMemoryGate.Severity.valueOf(
arguments?.getString(ARG_SEVERITY) ?: ModelMemoryGate.Severity.TIGHT.name
)
} catch (e: IllegalArgumentException) {
ModelMemoryGate.Severity.TIGHT
}

/**
* Delivers the outcome to the host fragment. Resolved on each call rather than captured at
* construction, so it still works on the instance the framework recreated after a rotation.
*/
private fun decide(proceed: Boolean) {
val host = parentFragment as? Host
if (host == null) {
// Nothing else reports this, and the load that raised the warning waits forever on it.
AiAssistantPlugin.getContext()?.logger
?.warn("MemoryWarningDialogFragment: no Host parent; decision dropped")
return
}
host.onModelMemoryDecision(proceed)
}

/** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide). */
private fun wireTooltip(view: View?, tag: String) {
view?.setOnLongClickListener { anchor ->
val service = tooltipService ?: return@setOnLongClickListener false
service.showTooltip(anchor, AiAssistantPlugin.TOOLTIP_CATEGORY, tag)
true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.itsaky.androidide.plugins.aiassistant.memory

import android.app.ActivityManager
import android.content.Context

/**
* Free RAM, as an interface so the pre-flight check is testable without a device.
*/
fun interface DeviceMemory {

/**
* Must be read at the moment of the check, never cached — the user may have just closed apps.
*
* @return free RAM in bytes, or null when it cannot be read. Null rather than a negative
* sentinel, so "unknown" cannot be confused with the reading of 0 a device under real
* pressure will genuinely report.
*/
fun availableBytes(): Long?
}

/**
* Reads free RAM from [ActivityManager], which needs no permission.
*
* @param contextProvider supplies the Android context, and null before the plugin is initialized
* @param onReadError reports why a reading could not be taken. The pre-flight only learns that one
* could not be, so without this the reason behind a silently skipped check is lost.
*/
class SystemDeviceMemory(
private val contextProvider: () -> Context?,
private val onReadError: (Throwable) -> Unit = {},
) : DeviceMemory {

/** Returns null rather than throwing; the gate treats "unknown" as "don't warn". */
override fun availableBytes(): Long? = try {
// applicationContext, so a provider that ever hands back an Activity can't be held here.
val context = contextProvider()?.applicationContext ?: return null
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
ActivityManager.MemoryInfo().also(manager::getMemoryInfo).availMem
} catch (e: Exception) {
onReadError(e)
null
}
}
Loading
Loading