diff --git a/README.md b/README.md index 928aed81..26c030cf 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`ai-core/`](ai-core/) | Shared on-device LLM inference backend (bundled llama.cpp AAR) plus a Gemini API backend, exposed to other plugins as a runtime service. | | [`ai-assistant/`](ai-assistant/) | In-IDE AI chat assistant with tool calling; talks to `ai-core` for inference over local or Gemini models. | | [`flutter-template/`](flutter-template/) | Adds Flutter starter project templates (Basic, BLoC, Provider, GetX, Riverpod) to the New Project screen. | +| [`code-suggestions-plugin/`](code-suggestions-plugin/) | Inline ghost-text code completions powered by AI. | +| [`speech-to-text-plugin/`](speech-to-text-plugin/) | Voice-to-code: converts speech to code with AI generation. | +| [`vector-search-plugin/`](vector-search-plugin/) | Semantic code search using embeddings and vector similarity. | ## Building a plugin diff --git a/ai-assistant/README.md b/ai-assistant/README.md index 54a729ef..5a01ae6b 100644 --- a/ai-assistant/README.md +++ b/ai-assistant/README.md @@ -56,6 +56,22 @@ The build resolves `plugin-api.jar` from the repo-root `../libs/`. - `fragments/AiSettingsFragment.kt`, `viewmodel/AiSettingsViewModel.kt` — model/backend config - `tool/` — the agent tool-loop (executor, router, per-tool handlers, approval) +## Security + +- **Gemini API key at rest.** When you use the Gemini backend, the API key is + stored in this plugin's private `SharedPreferences` (`AgentSettings`). That + store is **app-sandboxed** — other apps cannot read it — but it is **not + encrypted at rest**, so it is recoverable on a rooted or otherwise compromised + device. Remove it any time from **AI Settings** (or `clearGeminiApiKey()`). + Encryption is deliberately not layered on here: the key is shared at runtime + with the sibling `ai-core` plugin (which performs the Gemini calls), and + `EncryptedSharedPreferences` would force both independent plugins to agree on a + master-key alias and crypto library version — a fragile cross-plugin coupling. + A host-provided secure-storage service is the correct long-term fix. +- **Gemini API key in transit.** The key is sent to Google as an `x-goog-api-key` + request header (and via the SDK on the chat path), never in a URL query string. +- **File tools are confined to the project root** — see the in-IDE help page. + ## License GPL-3.0 — same as AndroidIDE / CodeOnTheGo. diff --git a/ai-assistant/ai-assistant.html b/ai-assistant/ai-assistant.html index 2d52facc..a0f5cf25 100644 --- a/ai-assistant/ai-assistant.html +++ b/ai-assistant/ai-assistant.html @@ -54,9 +54,8 @@

1. Executive Overview

  • AI Core — the inference engine. Provides an LlmInferenceService with two backends: a local, on-device model (llama.cpp) and Google Gemini. Install this first.
  • -
  • AI Assistant — the user interface. Contributes the Agent tab, the - editor context-menu actions, and the agent tool-loop that drives the - model.
  • +
  • AI Assistant — the user interface. Contributes the Agent tab and + the agent tool-loop that drives the model.
  • The two plugins are bridged by a shared service registry (SharedServices), so the UI plugin discovers the inference @@ -71,8 +70,6 @@

    2. Core Functionality

    and generate code from templates.
  • Dual inference backends — fully offline on-device inference, or Gemini in the cloud, selectable in Settings.
  • -
  • Editor context actionsExplain Code and - Generate Code from the editor selection.
  • Safety controls — filesystem tools are confined to the project root, and mutating tools require explicit user approval.
  • diff --git a/ai-assistant/src/main/AndroidManifest.xml b/ai-assistant/src/main/AndroidManifest.xml index 8b593826..f50acac3 100644 --- a/ai-assistant/src/main/AndroidManifest.xml +++ b/ai-assistant/src/main/AndroidManifest.xml @@ -25,7 +25,7 @@ + android:value="App Dev for All" /> + android:value="26.30" /> What the agent can do device. -

    Context-menu actions

    -

    Select code in the editor and open the context menu for - Explain Code and Generate Code shortcuts.

    -

    Troubleshooting

    • "No model configured" — select a .gguf file (Local) diff --git a/ai-assistant/src/main/assets/icon_day.png b/ai-assistant/src/main/assets/icon_day.png index 4cb1dc35..5dfa673e 100644 Binary files a/ai-assistant/src/main/assets/icon_day.png and b/ai-assistant/src/main/assets/icon_day.png differ diff --git a/ai-assistant/src/main/assets/icon_night.png b/ai-assistant/src/main/assets/icon_night.png index 355a5c0e..75ed2c65 100644 Binary files a/ai-assistant/src/main/assets/icon_night.png and b/ai-assistant/src/main/assets/icon_night.png differ diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt index 92cd5220..7138429b 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt @@ -3,7 +3,6 @@ package com.itsaky.androidide.plugins.aiassistant import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.extensions.UIExtension -import com.itsaky.androidide.plugins.extensions.ContextMenuContext import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.MenuItem import com.itsaky.androidide.plugins.extensions.PluginTooltipButton @@ -20,8 +19,20 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension { private var llmService: LlmInferenceService? = null companion object { + /** Must match `plugin.id` in AndroidManifest.xml — keys the host's plugin Context lookup + * used by [com.itsaky.androidide.plugins.base.PluginFragmentHelper.getPluginInflater]. */ + const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiassistant" + + /** Tooltip category for this plugin (strict `plugin_` convention); shared by the tab and the AI Settings screen. */ + const val TOOLTIP_CATEGORY = "plugin_$PLUGIN_ID" + const val TOOLTIP_TAG_TAB = "agent_chat_tab" + // Tags for the interactive controls on the AI Settings dialog (see AiSettingsFragment). + const val TOOLTIP_TAG_SETTINGS_BACKEND = "ai_settings_backend" + const val TOOLTIP_TAG_SETTINGS_LOCAL_MODEL = "ai_settings_local_model" + const val TOOLTIP_TAG_SETTINGS_GEMINI_KEY = "ai_settings_gemini_key" + @Volatile private var pluginContext: PluginContext? = null @@ -87,30 +98,6 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension { ) } - override fun getContextMenuItems(menuContext: ContextMenuContext): List { - val selectedText = menuContext.selectedText - if (selectedText.isNullOrBlank()) { - return emptyList() - } - - return listOf( - MenuItem( - id = "ai_explain_code", - title = "Explain Code", - isEnabled = true, - isVisible = true, - action = { context.logger.info("Explain Code clicked") } - ), - MenuItem( - id = "ai_generate_code", - title = "Generate Code", - isEnabled = true, - isVisible = true, - action = { context.logger.info("Generate Code clicked") } - ) - ) - } - override fun getMainMenuItems(): List = emptyList() // --- DocumentationExtension: three-tier tooltip help for the Agent tab --- @@ -120,7 +107,7 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension { // Tier 3 = `buttons[].uri` (offline HTML page served from // src/main/assets/docs/ at localhost) - override fun getTooltipCategory(): String = "plugin_ai_assistant" + override fun getTooltipCategory(): String = TOOLTIP_CATEGORY override fun getTooltipEntries(): List = listOf( PluginTooltipEntry( @@ -147,6 +134,50 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension { order = 0 ) ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_BACKEND, + summary = "Choose which model powers the Agent: on-device Local (llama.cpp) or cloud Gemini.", + detail = """ +

      Selects the active inference backend:

      +
        +
      • Local — runs a .gguf model entirely on + the device; nothing leaves the phone.
      • +
      • Gemini — calls Google's cloud API over HTTPS; needs + an API key.
      • +
      +

      The choice below changes which settings appear.

      + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_LOCAL_MODEL, + summary = "Pick a local .gguf chat model to run on-device.", + detail = """ +

      Browse for a .gguf model file to load with + llama.cpp. Use a chat/instruct model — embedding-only + 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.

      + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_GEMINI_KEY, + summary = "Enter your Google Gemini API key. It is stored only on this device.", + detail = """ +

      Paste a Gemini API key to enable the cloud backend. The key is + kept in this plugin's private preferences on-device and is sent + only to Google's API over HTTPS. Requests (your prompts and + project context) leave the device when Gemini is selected.

      + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) ) ) @@ -229,8 +260,6 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension { } } - // Note: Encrypted Gemini API key migration handled by EncryptedPrefs - if (migratedCount > 0) { context.logger.info("Migrated $migratedCount settings from app to plugin") } else { diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt index e9df31bc..fd94569e 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt @@ -27,7 +27,6 @@ import java.util.Date import java.util.Locale class ChatAdapter( - private val pluginContext: Context, private val markwon: Markwon, private val onMessageAction: (action: String, message: ChatMessage) -> Unit ) : ListAdapter(DiffCallback) { @@ -84,8 +83,8 @@ class ChatAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { android.util.Log.d("ChatAdapter", "onCreateViewHolder called, viewType=$viewType") - // Use plugin context for inflating layouts to access plugin resources - val inflater = LayoutInflater.from(pluginContext) + // Inflate from the RecyclerView's Context so item views follow the IDE day/night theme. + val inflater = LayoutInflater.from(parent.context) return when (viewType) { VIEW_TYPE_SYSTEM -> { val view = inflater.inflate(R.layout.list_item_chat_system_message, parent, false) diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt index 7ed32d68..83c285c1 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt @@ -13,7 +13,10 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin import com.itsaky.androidide.plugins.aiassistant.R +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import com.itsaky.androidide.plugins.services.IdeTooltipService import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiBackend import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiSettingsViewModel import com.itsaky.androidide.plugins.aiassistant.viewmodel.EngineState @@ -24,11 +27,17 @@ import java.util.Locale class AiSettingsFragment : DialogFragment() { + companion object { + /** FragmentResult key signalling the chat screen that settings were closed. */ + const val RESULT_SETTINGS_CLOSED = "ai_settings_closed" + } + private lateinit var viewModel: AiSettingsViewModel private lateinit var settingsToolbar: LinearLayout private lateinit var backButton: ImageButton private lateinit var backendSpinner: Spinner private lateinit var backendSpecificContainer: FrameLayout + private var tooltipService: IdeTooltipService? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -36,6 +45,23 @@ class AiSettingsFragment : DialogFragment() { // Plugin uses compileOnly dependencies, so Material transition resources aren't bundled enterTransition = null exitTransition = null + + // Resolve the IDE tooltip service so the settings controls can offer in-app help. + try { + tooltipService = 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. + } + } + + /** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide button). */ + private fun wireTooltip(view: View, tag: String) { + view.setOnLongClickListener { anchor -> + val service = tooltipService ?: return@setOnLongClickListener false + service.showTooltip(anchor, AiAssistantPlugin.TOOLTIP_CATEGORY, tag) + true + } } private val filePickerLauncher = @@ -54,18 +80,24 @@ class AiSettingsFragment : DialogFragment() { } } + /** + * Route inflation through the host so the dialog's views resolve against a Context whose + * Configuration tracks the IDE's day/night setting (DayNight PluginTheme + values-night/ + * colors). Replaces the old cloneInContext(pluginContext), which pinned the UI to light mode. + */ + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + val inflater = super.onGetLayoutInflater(savedInstanceState) + return com.itsaky.androidide.plugins.base.PluginFragmentHelper.getPluginInflater( + com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin.PLUGIN_ID, inflater + ) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - // Get plugin context to ensure proper resource inflation - val pluginContext = getPluginContext()?.androidContext ?: requireContext() - - // Create inflater with plugin context - val pluginInflater = inflater.cloneInContext(pluginContext) - - return pluginInflater.inflate(R.layout.fragment_ai_settings, container, false) + return inflater.inflate(R.layout.fragment_ai_settings, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -77,6 +109,15 @@ class AiSettingsFragment : DialogFragment() { setupBackendSelector() } + override fun onDismiss(dialog: android.content.DialogInterface) { + super.onDismiss(dialog) + // This is a dialog, so the chat screen behind it never gets onResume when we close. + // Signal it to re-resolve the selected backend (routing + availability + label). + if (isAdded) { + parentFragmentManager.setFragmentResult(RESULT_SETTINGS_CLOSED, Bundle.EMPTY) + } + } + private fun initializeViewModel() { viewModel = ViewModelProvider( this, @@ -113,6 +154,8 @@ class AiSettingsFragment : DialogFragment() { adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) backendSpinner.adapter = adapter + wireTooltip(backendSpinner, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_BACKEND) + val currentBackend = viewModel.getCurrentBackend() backendSpinner.setSelection(backends.indexOf(currentBackend)) updateBackendSpecificUi(currentBackend) @@ -131,18 +174,17 @@ class AiSettingsFragment : DialogFragment() { private fun updateBackendSpecificUi(backend: AiBackend) { backendSpecificContainer.removeAllViews() - // Use plugin context for inflating layouts - val pluginContext = getPluginContext()?.androidContext ?: requireContext() - + // Reuse the fragment's theme-aware inflater (routed through getPluginInflater) so these + // sub-layouts follow the IDE day/night theme like the rest of the dialog. when (backend) { AiBackend.LOCAL_LLM -> { - val localLlmView = LayoutInflater.from(pluginContext) + val localLlmView = layoutInflater .inflate(R.layout.layout_settings_local_llm, backendSpecificContainer, false) backendSpecificContainer.addView(localLlmView) setupLocalLlmUi(localLlmView) } AiBackend.GEMINI -> { - val geminiApiView = LayoutInflater.from(pluginContext) + val geminiApiView = layoutInflater .inflate(R.layout.layout_settings_gemini_api, backendSpecificContainer, false) backendSpecificContainer.addView(geminiApiView) setupGeminiApiUi(geminiApiView) @@ -162,6 +204,7 @@ class AiSettingsFragment : DialogFragment() { browseButton.setOnClickListener { filePickerLauncher.launch(arrayOf("*/*")) } + wireTooltip(browseButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_LOCAL_MODEL) loadSavedButton.setOnClickListener { val savedPath = viewModel.savedModelPath.value @@ -213,7 +256,7 @@ class AiSettingsFragment : DialogFragment() { if (path != null) { modelPathTextView.visibility = View.VISIBLE - val fileName = path.substringAfterLast("/") + val fileName = viewModel.getSavedModelName() ?: viewModel.fallbackDisplayName(path) modelPathTextView.text = "Saved: $fileName" } else { modelPathTextView.visibility = View.GONE @@ -252,6 +295,10 @@ class AiSettingsFragment : DialogFragment() { val clearButton = view.findViewById