Add force refresh support and state management improvements to plugin installer and model config

- Add force parameter to plugin index fetch with cache-busting headers and timestamp - Add openIndexView and reloadIndex methods to pluginInstallStore for explicit refresh - Add request sequence tracking to prevent race conditions in concurrent index loads - Move models summary state from component to store with loading/caching support - Add refreshModelsSummary, ensureModelsSummaryLoaded, and modal

frdel committed Mar 26, 2026 at 17:24 UTC c7a983638eead8068b69ba456715d5be04e6df8f
6 files changed +183 -62
plugins/_model_config/webui/model-config-store.js
+60
@@ -1,4 +1,5 @@
1 import { createStore } from "/js/AlpineStore.js";
2 +import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
3
4
5 export const MODEL_SECTIONS = [
@@ -60,6 +61,12 @@ export const store = createStore("modelConfig", {
61 globalPresets: [],
62 _presetsLoaded: false,
63
64 + // Settings include summary state
65 + modelsSummary: [],
66 + modelsSummaryLoading: false,
67 + _modelsSummaryLoaded: false,
68 + _modelsSummaryPromise: null,
69 +
70 // Switcher state
71 switcherAllowed: false,
72 switcherOverride: null,
@@ -464,6 +471,59 @@ export const store = createStore("modelConfig", {
471 ].map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
472 },
473
474 + async refreshModelsSummary() {
475 + if (this._modelsSummaryPromise) return await this._modelsSummaryPromise;
476 +
477 + this.modelsSummaryLoading = true;
478 + this._modelsSummaryPromise = (async () => {
479 + try {
480 + const models = await this.loadModelsSummary();
481 + this.modelsSummary = models;
482 + this._modelsSummaryLoaded = true;
483 + return models;
484 + } catch (e) {
485 + console.error('Failed to load models summary:', e);
486 + this.modelsSummary = [];
487 + this._modelsSummaryLoaded = true;
488 + return [];
489 + }
490 + })();
491 +
492 + try {
493 + return await this._modelsSummaryPromise;
494 + } finally {
495 + this._modelsSummaryPromise = null;
496 + this.modelsSummaryLoading = false;
497 + }
498 + },
499 +
500 + async ensureModelsSummaryLoaded() {
501 + if (this._modelsSummaryLoaded) return this.modelsSummary;
502 + return await this.refreshModelsSummary();
503 + },
504 +
505 + async openConfigFromSummary() {
506 + try {
507 + await pluginSettingsStore.openConfig('_model_config');
508 + } finally {
509 + await this.refreshModelsSummary();
510 + }
511 + },
512 +
513 + async openPresetsFromSummary() {
514 + await window.openModal?.('/plugins/_model_config/webui/main.html');
515 + },
516 +
517 + async openApiKeysFromSummary() {
518 + try {
519 + await window.openModal?.('/plugins/_model_config/webui/api-keys.html');
520 + } finally {
521 + await this.refreshApiKeyStatus().catch((e) => {
522 + console.error('Failed to refresh API key status:', e);
523 + });
524 + }
525 + },
526 +
527 // Switcher high-level methods
528 async refreshSwitcher(contextId) {
529 this.switcherLoading = true;
plugins/_model_config/webui/models-summary.html
+8 -12
@@ -7,7 +7,6 @@
7 <body>
8 <script type="module">
9 import { store } from "/plugins/_model_config/webui/model-config-store.js";
10 - import { store as pluginListStore } from "/components/plugins/list/pluginListStore.js";
10 </script>
11
12 <div x-data>
@@ -19,21 +18,18 @@
18 </div>
19
20 <!-- Read-only model config summary -->
22 - <div x-data="{ loading: true, models: [] }" x-init="
23 - models = await $store.modelConfig.loadModelsSummary().catch(() => []);
24 - loading = false;
25 - " class="model-summary">
21 + <div x-init="$store.modelConfig.refreshModelsSummary()" class="model-summary">
22 <div class="model-summary-header">
23 <div class="field-title">Current Models</div>
24 <div class="field-description">Active model configuration resolved from global, project, and agent profile
25 scopes.</div>
26 </div>
31 - <div x-show="loading" style="text-align:center; padding:12px;">
27 + <div x-show="$store.modelConfig.modelsSummaryLoading" style="text-align:center; padding:12px;">
28 <span class="material-symbols-outlined spinning" style="font-size:18px;">progress_activity</span>
29 </div>
34 - <template x-if="!loading && models.length">
30 + <template x-if="!$store.modelConfig.modelsSummaryLoading && $store.modelConfig.modelsSummary.length">
31 <div class="model-summary-grid">
36 - <template x-for="m in models" :key="m.title">
32 + <template x-for="m in $store.modelConfig.modelsSummary" :key="m.title">
33 <div class="model-summary-row">
34 <span class="material-symbols-outlined model-summary-icon" x-text="m.icon"></span>
35 <span class="model-summary-label" x-text="m.title"></span>
@@ -49,13 +45,13 @@
45 </div>
46
47 <div style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px;">
52 - <button class="btn btn-field" @click="$store.pluginListStore.openPluginConfig({ name: '_model_config', has_config_screen: true })">
48 + <button class="btn btn-field" @click="$store.modelConfig.openConfigFromSummary()">
49 Configure Models
50 </button>
55 - <button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/main.html')">
51 + <button class="btn btn-field" @click="$store.modelConfig.openPresetsFromSummary()">
52 Configure Presets
53 </button>
58 - <button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/api-keys.html')">
54 + <button class="btn btn-field" @click="$store.modelConfig.openApiKeysFromSummary()">
55 Configure API Keys
56 </button>
57 </div>
@@ -146,4 +142,4 @@
142 </style>
143 </body>
144
149 -</html>
\ No newline at end of file
145 +</html>
plugins/_plugin_installer/api/plugin_install.py
+8 -1
@@ -56,4 +56,11 @@ class PluginInstall(ApiHandler):
56 return update_from_git(input.get("plugin_name", ""))
57
58 def _fetch_index(self, input: dict) -> dict:
59 - return {"success": True, **get_plugin_hub_index()}
59 + force_raw = input.get("force", False)
60 + force = force_raw if isinstance(force_raw, bool) else str(force_raw).strip().lower() in {
61 + "1",
62 + "true",
63 + "yes",
64 + "on",
65 + }
66 + return {"success": True, **get_plugin_hub_index(force=force)}
plugins/_plugin_installer/helpers/install.py
+13 -4
@@ -286,9 +286,9 @@ def run_install_hook(plugin_name: str):
286 def run_pre_update_hook(plugin_name: str):
287 return plugins.call_plugin_hook(plugin_name, "pre_update")
288
289 -def get_plugin_hub_index() -> dict[str, Any]:
289 +def get_plugin_hub_index(force: bool = False) -> dict[str, Any]:
290 """Return the plugin index plus installed Plugin Hub keys."""
291 - index_data = fetch_plugin_index()
291 + index_data = fetch_plugin_index(force=force)
292 if not isinstance(index_data, dict):
293 raise ValueError("Plugin index response was not a JSON object")
294
@@ -339,10 +339,19 @@ def get_plugin_hub_index() -> dict[str, Any]:
339 return {"index": index_data, "installed_plugins": installed_keys}
340
341
342 -def fetch_plugin_index() -> dict:
342 +def fetch_plugin_index(force: bool = False) -> dict:
343 """Download the plugin index from GitHub releases."""
344 index_url = "https://github.com/agent0ai/a0-plugins/releases/download/generated-index/index.json"
345 - req = urllib.request.Request(index_url, headers={"User-Agent": "AgentZero"})
345 + if force:
346 + separator = "&" if "?" in index_url else "?"
347 + index_url = f"{index_url}{separator}ts={time.time_ns()}"
348 +
349 + headers = {"User-Agent": "AgentZero"}
350 + if force:
351 + headers["Cache-Control"] = "no-cache"
352 + headers["Pragma"] = "no-cache"
353 +
354 + req = urllib.request.Request(index_url, headers=headers)
355 with urllib.request.urlopen(req, timeout=30) as resp:
356 data = json.loads(resp.read().decode())
357 return data
plugins/_plugin_installer/webui/install-index.html
+24 -1
@@ -8,7 +8,7 @@
8 <body>
9 <div x-data>
10 <template x-if="$store.pluginInstallStore">
11 - <div class="pi-browse-shell">
11 + <div class="pi-browse-shell" x-create="$store.pluginInstallStore.openIndexView()">
12
13 <section class="pi-browse-hero">
14 <div class="pi-browse-copy">
@@ -39,6 +39,15 @@
39 <option value="name">Name</option>
40 </select>
41 </label>
42 +
43 + <button type="button"
44 + class="button icon-button pi-reload-button"
45 + aria-label="Reload Plugin Hub"
46 + title="Reload Plugin Hub"
47 + @click="$store.pluginInstallStore.reloadIndex()"
48 + :disabled="$store.pluginInstallStore.loading">
49 + <span class="material-symbols-outlined">refresh</span>
50 + </button>
51 </div>
52
53 <div class="pi-filter-row" x-show="$store.pluginInstallStore.browseFilters.length > 1">
@@ -268,6 +277,20 @@
277 justify-content: space-between;
278 }
279
280 + .pi-reload-button {
281 + flex-shrink: 0;
282 + display: inline-flex;
283 + align-items: center;
284 + justify-content: center;
285 + min-width: 0;
286 + min-height: 0;
287 + padding: 0.45rem 0.6rem;
288 + }
289 +
290 + .pi-reload-button .material-symbols-outlined {
291 + font-size: 1.1rem;
292 + }
293 +
294 .pi-field-label {
295 font-size: 0.8rem;
296 font-weight: 600;
plugins/_plugin_installer/webui/pluginInstallStore.js
+70 -44
@@ -36,6 +36,7 @@ const model = {
36 // Index state
37 index: { authors: {}, plugins: {} },
38 indexLoadPromise: null,
39 + indexLoadSeq: 0,
40 installedPlugins: [],
41 installedPluginDetails: {},
42 search: "",
@@ -64,10 +65,6 @@ const model = {
65 setTab(tab) {
66 this.activeTab = tab;
67 this.result = null;
67 - if (tab === "store") {
68 - this.resetIndex();
69 - void this.fetchIndex();
70 - }
68 },
69
70 setBrowseFilter(filter) {
@@ -281,62 +278,91 @@ const model = {
278 },
279
280 async fetchIndex(options = {}) {
281 + const force = !!options?.force;
282 const background = !!options?.background;
283 const suppressErrors = !!options?.suppressErrors;
286 - if (this.indexLoadPromise) {
287 - return this.indexLoadPromise;
284 + if (!force && this.indexLoadPromise) {
285 + if (background) {
286 + return this.indexLoadPromise;
287 + }
288 +
289 + this.loading = true;
290 + this.loadingMessage = "Loading plugin index...";
291 + try {
292 + return await this.indexLoadPromise;
293 + } finally {
294 + this.loading = false;
295 + this.loadingMessage = "";
296 + }
297 }
298
299 + const requestSeq = ++this.indexLoadSeq;
300 const loadPromise = (async () => {
291 - try {
292 - if (!background) {
293 - this.loading = true;
294 - this.loadingMessage = "Loading plugin index...";
295 - }
301 + try {
302 + if (!background) {
303 + this.loading = true;
304 + this.loadingMessage = "Loading plugin index...";
305 + }
306
297 - const data = await api.callJsonApi(PLUGIN_API, {
298 - action: "fetch_index",
299 - });
307 + const data = await api.callJsonApi(PLUGIN_API, {
308 + action: "fetch_index",
309 + force,
310 + });
311
301 - if (!data.success) {
302 - if (!suppressErrors) {
303 - void toastFrontendError(data.error || "Failed to load index", "Plugin Installer");
312 + if (!data.success) {
313 + if (!suppressErrors && requestSeq === this.indexLoadSeq) {
314 + void toastFrontendError(data.error || "Failed to load index", "Plugin Installer");
315 + }
316 + return false;
317 }
305 - return false;
306 - }
318
308 - this.index = data.index;
309 - this.installedPlugins = data.installed_plugins || [];
310 - const installedResponse = await api.callJsonApi("plugins_list", {
311 - filter: { custom: true, builtin: false, search: "" },
312 - });
313 - const installedList = Array.isArray(installedResponse.plugins) ? installedResponse.plugins : [];
314 - this.installedPluginDetails = Object.fromEntries(
315 - installedList.map((plugin) => [plugin.name, plugin])
316 - );
317 - this.page = 1;
318 - return true;
319 - } catch (e) {
320 - const message = e instanceof Error ? e.message : String(e);
321 - if (!suppressErrors) {
322 - void toastFrontendError(`Failed to load plugin index: ${message}`, "Plugin Installer");
323 - }
324 - return false;
325 - } finally {
326 - if (!background) {
327 - this.loading = false;
328 - this.loadingMessage = "";
319 + const installedResponse = await api.callJsonApi("plugins_list", {
320 + filter: { custom: true, builtin: false, search: "" },
321 + });
322 + const installedList = Array.isArray(installedResponse.plugins) ? installedResponse.plugins : [];
323 +
324 + if (requestSeq !== this.indexLoadSeq) {
325 + return false;
326 + }
327 +
328 + this.index = data.index;
329 + this.installedPlugins = data.installed_plugins || [];
330 + this.installedPluginDetails = Object.fromEntries(
331 + installedList.map((plugin) => [plugin.name, plugin])
332 + );
333 + this.page = 1;
334 + return true;
335 + } catch (e) {
336 + const message = e instanceof Error ? e.message : String(e);
337 + if (!suppressErrors && requestSeq === this.indexLoadSeq) {
338 + void toastFrontendError(`Failed to load plugin index: ${message}`, "Plugin Installer");
339 + }
340 + return false;
341 + } finally {
342 + if (!background && requestSeq === this.indexLoadSeq) {
343 + this.loading = false;
344 + this.loadingMessage = "";
345 + }
346 }
330 - }
347 })();
348
333 - this.indexLoadPromise = loadPromise.finally(() => {
334 - if (this.indexLoadPromise === loadPromise) {
349 + const trackedPromise = loadPromise.finally(() => {
350 + if (this.indexLoadPromise === trackedPromise) {
351 this.indexLoadPromise = null;
352 }
353 });
354 + this.indexLoadPromise = trackedPromise;
355 +
356 + return trackedPromise;
357 + },
358 +
359 + async openIndexView() {
360 + this.resetIndex();
361 + return this.fetchIndex({ force: true });
362 + },
363
339 - return this.indexLoadPromise;
364 + async reloadIndex() {
365 + return this.fetchIndex({ force: true });
366 },
367
368 async ensureIndexLoaded(options = {}) {