Skip to content

Commit 8ef6f88

Browse files
committed
feat(vector-search): add Semantic Search settings screen
Shows backend support, picks the embedding model and reports index state, progress and build history. SemanticSearchSourceImpl is rebuilt with the IndexCoordinator on each activate. Refs: ADFA-6303
1 parent c5629f8 commit 8ef6f88

59 files changed

Lines changed: 4055 additions & 951 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
android:value="App Dev for All" />
4141

4242
<!-- 26.41: the first release whose plugin-api carries the capability-tag contract;
43-
this backend reports getActiveModelName() and notifyBackendChanged() (ADFA-6278).
43+
this backend reports getActiveModelName() and notifyBackendChanged() (ADFA-6278), and
44+
implements EmbeddingModelSelectable for Vector Search's settings screen (ADFA-6303).
4445
Do not lower it. -->
4546
<meta-data
4647
android:name="plugin.min_ide_version"

‎plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt‎

Lines changed: 135 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class GeminiBackend(
6161
private val context: PluginContext,
6262
private val promptConfig: () -> GeminiPromptConfig?,
6363
) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend,
64-
EmbeddingBackend, WebSearchBackend, ActiveModelReportingBackend {
64+
EmbeddingModelSelectable, WebSearchBackend, ActiveModelReportingBackend {
6565

6666
private val scope = CoroutineScope(Dispatchers.IO)
6767

@@ -162,6 +162,43 @@ class GeminiBackend(
162162
?.trim()?.takeIf { it.isNotEmpty() }
163163
?: DEFAULT_EMBEDDING_MODEL
164164

165+
/**
166+
* The models the saved key can embed with: those that advertise `embedContent`.
167+
*
168+
* Fails rather than answering empty without a key, so the screen offering the choice can say
169+
* why there is no list instead of drawing an empty one.
170+
*
171+
* @return the embedding models in catalog order, or a future completed exceptionally with the
172+
* sentence a refused chat turn shows, or, with no usable key, why none could be read
173+
*/
174+
override fun listEmbeddingModels(): CompletableFuture<List<String>> {
175+
val keyStamp = storedKeyStamp()
176+
return launchFuture(
177+
describeFailure = { e ->
178+
context.logger.warn("GeminiBackend: listing embedding models failed: ${e.message}")
179+
IOException(formatErrorMessage(e, keyStamp), e)
180+
},
181+
) {
182+
val apiKey = readGeminiApiKey()
183+
if (apiKey.isNullOrBlank()) throw ReportedFailure(missingKeyMessage())
184+
describeCatalog(apiKey).embedding
185+
}
186+
}
187+
188+
/**
189+
* Stores [modelId] where [getEmbeddingModelId] reads it.
190+
*
191+
* The plugin watches this key, so writing it is what tells AI Core's listeners; setting the
192+
* current model again writes nothing and so tells nobody.
193+
*/
194+
override fun setEmbeddingModelId(modelId: String) {
195+
val model = modelId.trim()
196+
require(model.isNotEmpty()) { "The embedding model id must not be empty" }
197+
if (model == getEmbeddingModelId()) return
198+
agentPrefs()?.edit()?.putString(GeminiPreferences.KEY_EMBEDDING_MODEL, model)?.apply()
199+
context.logger.info("GeminiBackend: embedding model set to $model")
200+
}
201+
165202
/**
166203
* Vector length the API last produced for the configured embedding model.
167204
*
@@ -183,42 +220,20 @@ class GeminiBackend(
183220
* store a partial space
184221
*/
185222
override fun embed(texts: List<String>): CompletableFuture<List<FloatArray>> {
186-
val future = CompletableFuture<List<FloatArray>>()
187-
if (texts.isEmpty()) {
188-
future.complete(emptyList())
189-
return future
190-
}
191-
// close() cancels the scope, making launch a silent no-op; fail loudly instead.
192-
if (!scope.isActive) {
193-
future.completeExceptionally(IllegalStateException("Gemini backend is closed"))
194-
return future
195-
}
196-
197-
val job = scope.launch {
198-
val keyStamp = storedKeyStamp()
199-
try {
200-
val apiKey = readGeminiApiKey()
201-
if (apiKey.isNullOrBlank()) {
202-
// The same refusal a chat turn reports, through the same formatter.
203-
future.completeExceptionally(
204-
IOException(userMessage(GeminiFailure.KeyInvalid))
205-
)
206-
return@launch
207-
}
208-
future.complete(embedBatches(texts, apiKey))
209-
} catch (e: CancellationException) {
210-
future.cancel(true)
211-
throw e
212-
} catch (e: Exception) {
223+
if (texts.isEmpty()) return CompletableFuture.completedFuture(emptyList())
224+
val keyStamp = storedKeyStamp()
225+
return launchFuture(
226+
describeFailure = { e ->
213227
context.logger.error("GeminiBackend: embedding ${texts.size} texts failed", e)
214228
// The formatted sentence, not the raw body: this message reaches the user through
215229
// whichever consumer asked, exactly as a refused chat turn's does.
216-
future.completeExceptionally(IOException(formatErrorMessage(e, keyStamp), e))
217-
}
230+
IOException(formatErrorMessage(e, keyStamp), e)
231+
},
232+
) {
233+
val apiKey = readGeminiApiKey()
234+
if (apiKey.isNullOrBlank()) throw ReportedFailure(missingKeyMessage())
235+
embedBatches(texts, apiKey)
218236
}
219-
future.cancelJobOnCancel(job)
220-
221-
return future
222237
}
223238

224239
/**
@@ -670,36 +685,22 @@ class GeminiBackend(
670685
* network/API failure, so the caller can fall back to a current-models-only list and
671686
* never advertise a dead model.
672687
*/
673-
internal fun listCatalog(): CompletableFuture<ModelCatalog> {
674-
val future = CompletableFuture<ModelCatalog>()
675-
// close() cancels the scope, making launch a silent no-op; fail loudly instead, or the
676-
// gateway's blocking get() would sit at "Loading" for its full 60-second timeout.
677-
if (!scope.isActive) {
678-
future.completeExceptionally(IllegalStateException("Gemini backend is closed"))
679-
return future
680-
}
681-
682-
val job = scope.launch {
683-
try {
684-
val key = readGeminiApiKey()
685-
if (key.isNullOrBlank()) {
686-
context.logger.warn("GeminiBackend: no API key configured; cannot list live models")
687-
future.complete(ModelCatalog.EMPTY)
688-
return@launch
689-
}
690-
future.complete(describeCatalog(key))
691-
} catch (e: CancellationException) {
692-
future.cancel(true)
693-
throw e
694-
} catch (e: Exception) {
688+
internal fun listCatalog(): CompletableFuture<ModelCatalog> =
689+
// A closed scope fails at once, not after the gateway's blocking 60-second get().
690+
launchFuture(
691+
describeFailure = { e ->
695692
context.logger.error("GeminiBackend: Error in listCatalog", e)
696-
future.completeExceptionally(e)
693+
e
694+
},
695+
) {
696+
val key = readGeminiApiKey()
697+
if (key.isNullOrBlank()) {
698+
context.logger.warn("GeminiBackend: no API key configured; cannot list live models")
699+
ModelCatalog.EMPTY
700+
} else {
701+
describeCatalog(key)
697702
}
698703
}
699-
future.cancelJobOnCancel(job)
700-
701-
return future
702-
}
703704

704705
/**
705706
* List the models a caller-supplied [apiKey] can use, instead of the one saved on disk.
@@ -713,32 +714,19 @@ class GeminiBackend(
713714
* status code out of that message to tell a refused key from an unreachable network
714715
*/
715716
internal fun listCatalog(apiKey: String): CompletableFuture<ModelCatalog> {
716-
val future = CompletableFuture<ModelCatalog>()
717717
val key = apiKey.trim()
718718
if (key.isEmpty()) {
719-
future.completeExceptionally(IllegalArgumentException("Gemini API key is blank"))
720-
return future
721-
}
722-
// close() cancels the scope, making launch a silent no-op; fail loudly instead.
723-
if (!scope.isActive) {
724-
future.completeExceptionally(IllegalStateException("Gemini backend is closed"))
725-
return future
726-
}
727-
728-
val job = scope.launch {
729-
try {
730-
future.complete(describeCatalog(key))
731-
} catch (e: CancellationException) {
732-
future.cancel(true)
733-
throw e
734-
} catch (e: Exception) {
735-
context.logger.warn("GeminiBackend: candidate key check failed: ${e.message}")
736-
future.completeExceptionally(e)
719+
// Not CompletableFuture.failedFuture: that is API 31, and the host still runs on 28.
720+
return CompletableFuture<ModelCatalog>().apply {
721+
completeExceptionally(IllegalArgumentException("Gemini API key is blank"))
737722
}
738723
}
739-
future.cancelJobOnCancel(job)
740-
741-
return future
724+
return launchFuture(
725+
describeFailure = { e ->
726+
context.logger.warn("GeminiBackend: candidate key check failed: ${e.message}")
727+
e
728+
},
729+
) { describeCatalog(key) }
742730
}
743731

744732
/**
@@ -1098,6 +1086,65 @@ class GeminiBackend(
10981086
}
10991087
}
11001088

1089+
/**
1090+
* Runs [block] on this backend's scope and completes the returned future with its value.
1091+
*
1092+
* Cancelling the future cancels [block], and a closed backend fails the future at once: after
1093+
* close() cancels the scope, launch is a silent no-op that would leave it pending forever.
1094+
*
1095+
* @param describeFailure turns what [block] threw into what the future fails with; a
1096+
* [ReportedFailure] skips it, being already in the user's words
1097+
*/
1098+
private fun <T> launchFuture(
1099+
describeFailure: (Exception) -> Throwable,
1100+
block: suspend () -> T,
1101+
): CompletableFuture<T> {
1102+
val future = CompletableFuture<T>()
1103+
if (!scope.isActive) {
1104+
future.completeExceptionally(IllegalStateException("Gemini backend is closed"))
1105+
return future
1106+
}
1107+
1108+
val job = scope.launch {
1109+
try {
1110+
future.complete(block())
1111+
} catch (e: CancellationException) {
1112+
future.cancel(true)
1113+
throw e
1114+
} catch (e: ReportedFailure) {
1115+
future.completeExceptionally(e)
1116+
} catch (e: Exception) {
1117+
future.completeExceptionally(describeFailure(e))
1118+
}
1119+
}
1120+
future.cancelJobOnCancel(job)
1121+
return future
1122+
}
1123+
1124+
/**
1125+
* Why no key could be used, in words that do not blame a key that was never sent: none saved,
1126+
* one this device can no longer decrypt, or a Keystore that did not answer this time.
1127+
*/
1128+
private fun missingKeyMessage(): String {
1129+
val stored = runCatching {
1130+
secureApiKeyStore.readAndMigrate(agentPrefs(), GeminiPreferences.KEY_API_KEY)
1131+
}.getOrNull()
1132+
val res = when (stored) {
1133+
KeystoreSecretStore.Stored.Absent -> R.string.gemini_error_key_missing
1134+
is KeystoreSecretStore.Stored.Value ->
1135+
// Readable now but not a moment ago, or saved blank; only blank lasts.
1136+
if (stored.plain.isBlank()) R.string.gemini_error_key_missing
1137+
else R.string.msg_api_key_unavailable
1138+
KeystoreSecretStore.Stored.Unreadable -> R.string.msg_api_key_unreadable
1139+
KeystoreSecretStore.Stored.Unavailable, null -> R.string.msg_api_key_unavailable
1140+
}
1141+
return try {
1142+
context.androidContext.getString(res)
1143+
} catch (e: Exception) {
1144+
"No usable Gemini API key"
1145+
}
1146+
}
1147+
11011148
/**
11021149
* Turn a failure into one user-facing sentence.
11031150
*
@@ -1181,6 +1228,12 @@ class GeminiBackend(
11811228
}
11821229
}
11831230

1231+
/**
1232+
* A failure already worded for the user, which [GeminiBackend]'s future helper passes on as it is
1233+
* instead of classifying it again as a transport error.
1234+
*/
1235+
private class ReportedFailure(message: String) : IOException(message)
1236+
11841237
/**
11851238
* Cancel [job] when this future is cancelled by its caller.
11861239
*

‎plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/ModelCatalog.kt‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
package com.itsaky.androidide.plugins.aiagentgemini.backend
22

33
/**
4-
* One key's catalog, split into the models each picker may offer.
5-
*
6-
* Carried as one value because both halves come from one paginated `ListModels` walk, filtered on
7-
* the capability each model declares: fetching them separately would pay for the walk twice and
8-
* let the two pickers describe different snapshots.
4+
* One key's catalog, split by declared capability: chat models for this plugin's settings pane,
5+
* embedding models for Vector Search's. One value because both come from one `ListModels` walk.
96
*
107
* @param chat models that advertise `generateContent`
118
* @param embedding models that advertise `embedContent`

‎plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt‎

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,17 @@ class GeminiPlugin : IPlugin, DocumentationExtension {
4949
// Tags for the controls on this backend's settings pane (see GeminiSettingsFragment).
5050
const val TOOLTIP_TAG_SETTINGS_GEMINI_KEY = "ai_gemini_key"
5151
const val TOOLTIP_TAG_SETTINGS_GEMINI_MODEL = "ai_gemini_model"
52-
const val TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL = "ai_gemini_embedding_model"
5352
const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_gemini_get_free_key"
5453

55-
/** The settings that change what [GeminiBackend.isAvailable] or its model name answers. */
56-
private val WATCHED_KEYS = setOf(GeminiPreferences.KEY_API_KEY, GeminiPreferences.KEY_MODEL)
54+
/**
55+
* The settings that change what [GeminiBackend.isAvailable], its model name or its
56+
* embedding model answers; the last is set from Vector Search, which re-reads on notice.
57+
*/
58+
private val WATCHED_KEYS = setOf(
59+
GeminiPreferences.KEY_API_KEY,
60+
GeminiPreferences.KEY_MODEL,
61+
GeminiPreferences.KEY_EMBEDDING_MODEL,
62+
)
5763

5864
@Volatile
5965
private var pluginContext: PluginContext? = null
@@ -220,22 +226,6 @@ class GeminiPlugin : IPlugin, DocumentationExtension {
220226
current models is shown instead.</p>
221227
""".trimIndent(),
222228
),
223-
PluginTooltipEntry(
224-
tag = TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL,
225-
summary = "Which model turns your code into vectors for semantic search. Never used for chat.",
226-
detail = """
227-
<p>Semantic search compares meaning rather than words, which it
228-
does by embedding every chunk of the project with this model. It
229-
is a separate setting because no Gemini model does both: this list
230-
holds the models that advertise <code>embedContent</code>, and the
231-
<b>Model</b> list above holds those that advertise
232-
<code>generateContent</code>.</p>
233-
<p>Changing it changes the vector space, so the project is indexed
234-
again from scratch. Vectors from two different models are not
235-
comparable, and mixing them would quietly return worse results
236-
rather than fail.</p>
237-
""".trimIndent(),
238-
),
239229
PluginTooltipEntry(
240230
tag = TOOLTIP_TAG_SETTINGS_GET_KEY,
241231
summary = "Opens Google AI Studio in your browser, where API keys are free to create.",

‎plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,11 @@ package com.itsaky.androidide.plugins.aiagentgemini.settings
99
sealed interface CatalogResult {
1010

1111
/**
12-
* The backend answered. Either list may be empty, which for [models] is itself suspicious for
13-
* a valid key.
14-
*
15-
* Both halves come from one paginated walk, split by the method each model declares, so they
16-
* describe the same snapshot of the same key.
12+
* The backend answered. The list may be empty, which is itself suspicious for a valid key.
1713
*
1814
* @param models the chat-capable models the key can reach
19-
* @param embeddingModels the embedding-capable models the key can reach
2015
*/
21-
data class Success(
22-
val models: List<String>,
23-
val embeddingModels: List<String>,
24-
) : CatalogResult
16+
data class Success(val models: List<String>) : CatalogResult
2517

2618
/** No "gemini" backend was resolvable — ai-core or ai-agent-gemini is missing, disabled,
2719
* or not yet active. */

‎plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ class BackendGeminiCatalogGateway(
9696
return try {
9797
val catalog = future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS)
9898
?: ModelCatalog.EMPTY
99-
CatalogResult.Success(catalog.chat, catalog.embedding)
99+
CatalogResult.Success(catalog.chat)
100100
} catch (e: ExecutionException) {
101101
// The API failure the backend reported; its message carries the HTTP status.
102102
CatalogResult.Failed(e.cause ?: e)

0 commit comments

Comments
 (0)