Refactor plugin installer UI and thumbnail handling

Fix empty-chat handling and refactor the plugin installer web UI and store. models.py: preserve messages with empty content by setting content to "empty" (prevents breaking some LLMs). plugin_installer web UI: tweak copy to emphasize community plugins and add contribution link, simplify markup/whitespace, and change install-detail thumbnail error to clear detailThumbnailUrl. pluginInstallStore.js: major refactor — import pluginListStore directly, comment out unused stores, initialize index to an empty structure, replace multiple thumbnail sources with a single detailThumbnailUrl, add a robust _pluginName sanitizer for GitHub URLs, set selectedPlugin.name on openDetail, and route manage/open/delete actions through pluginListStore. Removed unused thumbnail helper/state and getStore lookups; refresh now calls pluginListStore.refresh(). Misc: small CSS rgba formatting and final newline fix.

frdel committed Mar 9, 2026 at 19:51 UTC 5c2ee20cee5e7e95a309f19b704c66ef23abd009
5 files changed +87 -130
models.py
+3 -2
@@ -361,11 +361,12 @@ class LiteLLMChatWrapper(SimpleChatModel):
361 if tool_call_id:
362 message_dict["tool_call_id"] = tool_call_id
363
364 - # Skip messages with empty content
364 + # fix messages with empty content, this breaks some LLMs
365 content = message_dict.get("content")
366 has_content = bool(content) if not isinstance(content, list) else len(content) > 0
367 if not has_content:
368 - continue
368 + message_dict["content"] = "empty"
369 +
370 result.append(message_dict)
371
372 if explicit_caching and result:
plugins/plugin_installer/webui/install-detail.html
+1 -1
@@ -15,7 +15,7 @@
15 <template x-if="$store.pluginInstallStore.getDetailThumbnailUrl()">
16 <img :src="$store.pluginInstallStore.getDetailThumbnailUrl()"
17 :alt="$store.pluginInstallStore.selectedPlugin.title"
18 - @error="$store.pluginInstallStore.nextDetailThumbnail()">
18 + @error="$store.pluginInstallStore.detailThumbnailUrl = null">
19 </template>
20 <template x-if="!$store.pluginInstallStore.getDetailThumbnailUrl()">
21 <span class="material-symbols-outlined pi-hero-placeholder">extension</span>
plugins/plugin_installer/webui/install-index.html
+2 -2
@@ -15,9 +15,9 @@
15 <section class="pi-browse-hero">
16 <div class="pi-browse-copy">
17 <div class="pi-browse-eyebrow">Community Marketplace</div>
18 - <h2 class="pi-browse-title">Discover plugins for Agent Zero</h2>
18 + <h2 class="pi-browse-title">Discover community plugins for Agent Zero</h2>
19 <p class="pi-browse-description">
20 - Browse community plugins, filter by what you already have installed, and open each listing for screenshots, README, and install actions.
20 + These plugins are provided by the community of Agent Zero. Contribute by publishing in the <a href="https://github.com/agent0ai/a0-plugins" target="_blank">index repository</a>.
21 </p>
22 </div>
23 <div class="pi-browse-summary" x-text="$store.pluginInstallStore.browseResultsSummary"></div>
plugins/plugin_installer/webui/main.html
+45 -48
@@ -1,38 +1,34 @@
1 <html>
2 +
3 <head>
4 <title>Install Plugin</title>
5 <script type="module">
6 import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
7 </script>
8 </head>
9 +
10 <body>
11 <div x-data>
12 <template x-if="$store.pluginInstallStore">
13 <div x-init="$store.pluginInstallStore.setTab('store')"
12 - x-destroy="$store.pluginInstallStore.refreshPluginList()">
14 + x-destroy="$store.pluginInstallStore.refreshPluginList()">
15
16 <ul class="nav nav-tabs" role="tablist">
17 <li class="nav-item" role="presentation">
16 - <button class="nav-link"
17 - :class="{ active: $store.pluginInstallStore.activeTab === 'store' }"
18 - type="button" role="tab"
19 - @click="$store.pluginInstallStore.setTab('store')">
18 + <button class="nav-link" :class="{ active: $store.pluginInstallStore.activeTab === 'store' }"
19 + type="button" role="tab" @click="$store.pluginInstallStore.setTab('store')">
20 <span class="material-symbols-outlined pi-tab-icon">store</span> Browse
21 </button>
22 </li>
23 <li class="nav-item" role="presentation">
24 - <button class="nav-link"
25 - :class="{ active: $store.pluginInstallStore.activeTab === 'git' }"
26 - type="button" role="tab"
27 - @click="$store.pluginInstallStore.setTab('git')">
24 + <button class="nav-link" :class="{ active: $store.pluginInstallStore.activeTab === 'git' }"
25 + type="button" role="tab" @click="$store.pluginInstallStore.setTab('git')">
26 <span class="material-symbols-outlined pi-tab-icon">terminal</span> Git
27 </button>
28 </li>
29 <li class="nav-item" role="presentation">
32 - <button class="nav-link"
33 - :class="{ active: $store.pluginInstallStore.activeTab === 'zip' }"
34 - type="button" role="tab"
35 - @click="$store.pluginInstallStore.setTab('zip')">
30 + <button class="nav-link" :class="{ active: $store.pluginInstallStore.activeTab === 'zip' }"
31 + type="button" role="tab" @click="$store.pluginInstallStore.setTab('zip')">
32 <span class="material-symbols-outlined pi-tab-icon">upload_file</span> ZIP
33 </button>
34 </li>
@@ -40,34 +36,34 @@
36
37 <!-- ═══ Store Tab ═══ -->
38 <div x-show="$store.pluginInstallStore.activeTab === 'store'"
43 - x-init="$store.pluginInstallStore.resetIndex(); $store.pluginInstallStore.fetchIndex()">
39 + x-init="$store.pluginInstallStore.resetIndex(); $store.pluginInstallStore.fetchIndex()">
40
41 <div class="pi-browse-shell">
42 <section class="pi-browse-hero">
43 <div class="pi-browse-copy">
44 <div class="pi-browse-eyebrow">Community Marketplace</div>
49 - <h2 class="pi-browse-title">Discover plugins for Agent Zero</h2>
45 + <h2 class="pi-browse-title">Discover community plugins for Agent Zero</h2>
46 <p class="pi-browse-description">
51 - Browse community plugins, filter by what you already have installed, and open each listing for screenshots, README, and install actions.
47 + These plugins are provided by the community of Agent Zero. Contribute by publishing
48 + in the <a href="https://github.com/agent0ai/a0-plugins" target="_blank">index
49 + repository</a>.
50 </p>
51 </div>
54 - <div class="pi-browse-summary" x-text="$store.pluginInstallStore.browseResultsSummary"></div>
52 + <div class="pi-browse-summary" x-text="$store.pluginInstallStore.browseResultsSummary">
53 + </div>
54 </section>
55
56 <div class="pi-index-toolbar">
57 <label class="pi-search-field">
58 <span class="material-symbols-outlined">search</span>
60 - <input type="text"
61 - class="pi-search-input"
62 - x-model="$store.pluginInstallStore.search"
59 + <input type="text" class="pi-search-input" x-model="$store.pluginInstallStore.search"
60 @input="$store.pluginInstallStore.page = 1"
61 placeholder="Search plugins, authors, or tags...">
62 </label>
63
64 <label class="pi-sort-field">
65 <span class="pi-field-label">Sort</span>
69 - <select class="pi-sort-select"
70 - x-model="$store.pluginInstallStore.sortBy">
66 + <select class="pi-sort-select" x-model="$store.pluginInstallStore.sortBy">
67 <option value="stars">Stars</option>
68 <option value="name">Name</option>
69 </select>
@@ -76,8 +72,7 @@
72
73 <div class="pi-filter-row" x-show="$store.pluginInstallStore.browseFilters.length > 1">
74 <template x-for="filter in $store.pluginInstallStore.browseFilters" :key="filter.key">
79 - <button type="button"
80 - class="pi-filter-chip"
75 + <button type="button" class="pi-filter-chip"
76 :class="{ active: $store.pluginInstallStore.browseFilter === filter.key }"
77 @click="$store.pluginInstallStore.setBrowseFilter(filter.key)">
78 <span x-text="filter.label"></span>
@@ -90,11 +85,13 @@
85 <span class="material-symbols-outlined">progress_activity</span>
86 <div>
87 <div class="pi-state-title">Loading marketplace</div>
93 - <div class="pi-state-text" x-text="$store.pluginInstallStore.loadingMessage || 'Loading plugins...'"></div>
88 + <div class="pi-state-text"
89 + x-text="$store.pluginInstallStore.loadingMessage || 'Loading plugins...'"></div>
90 </div>
91 </div>
92
97 - <div x-show="$store.pluginInstallStore.error && !$store.pluginInstallStore.loading" class="pi-state-card pi-state-error">
93 + <div x-show="$store.pluginInstallStore.error && !$store.pluginInstallStore.loading"
94 + class="pi-state-card pi-state-error">
95 <span class="material-symbols-outlined">error</span>
96 <div>
97 <div class="pi-state-title">Marketplace unavailable</div>
@@ -105,7 +102,8 @@
102 <div x-show="$store.pluginInstallStore.index && !$store.pluginInstallStore.loading && $store.pluginInstallStore.filteredPlugins.length > 0"
103 class="pi-grid">
104 <template x-for="plugin in $store.pluginInstallStore.paginatedPlugins" :key="plugin.key">
108 - <button type="button" class="pi-card" @click="$store.pluginInstallStore.openDetail(plugin)">
105 + <button type="button" class="pi-card"
106 + @click="$store.pluginInstallStore.openDetail(plugin)">
107 <div class="pi-card-thumb">
108 <template x-if="plugin.thumbnail">
109 <img :src="plugin.thumbnail" :alt="plugin.title" loading="lazy">
@@ -163,8 +161,7 @@
161 </div>
162
163 <div x-show="$store.pluginInstallStore.totalPages > 1" class="pi-pagination">
166 - <button class="button"
167 - :disabled="$store.pluginInstallStore.page <= 1"
164 + <button class="button" :disabled="$store.pluginInstallStore.page <= 1"
165 @click="$store.pluginInstallStore.setPage($store.pluginInstallStore.page - 1)">
166 <span class="material-symbols-outlined">chevron_left</span>
167 </button>
@@ -182,28 +179,25 @@
179
180 <!-- ═══ Git Tab ═══ -->
181 <div x-show="$store.pluginInstallStore.activeTab === 'git'"
185 - x-init="$store.pluginInstallStore.resetGit()">
182 + x-init="$store.pluginInstallStore.resetGit()">
183
184 <div class="pi-form-group">
185 <label class="pi-label">Git Repository URL</label>
189 - <input type="text" class="pi-input"
190 - x-model="$store.pluginInstallStore.gitUrl"
186 + <input type="text" class="pi-input" x-model="$store.pluginInstallStore.gitUrl"
187 :disabled="$store.pluginInstallStore.loading"
188 placeholder="https://github.com/user/my-plugin.git">
189 </div>
190
191 <div class="pi-form-group">
196 - <label class="pi-label">Access Token <span class="pi-optional">(optional, for private repos)</span></label>
197 - <input type="password" class="pi-input"
198 - x-model="$store.pluginInstallStore.gitToken"
199 - :disabled="$store.pluginInstallStore.loading"
200 - placeholder="ghp_xxxx or glpat-xxxx">
192 + <label class="pi-label">Access Token <span class="pi-optional">(optional, for private
193 + repos)</span></label>
194 + <input type="password" class="pi-input" x-model="$store.pluginInstallStore.gitToken"
195 + :disabled="$store.pluginInstallStore.loading" placeholder="ghp_xxxx or glpat-xxxx">
196 <div class="pi-hint">Token is used only for cloning and is not stored.</div>
197 </div>
198
199 <div class="pi-actions">
205 - <button class="button confirm"
206 - @click="$store.pluginInstallStore.installGit()"
200 + <button class="button confirm" @click="$store.pluginInstallStore.installGit()"
201 :disabled="$store.pluginInstallStore.loading || !$store.pluginInstallStore.gitUrl.trim()">
202 <template x-if="$store.pluginInstallStore.loading">
203 <span class="pi-button-loading">
@@ -228,7 +222,8 @@
222 <span class="material-symbols-outlined">check_circle</span>
223 </div>
224 <div class="pi-result-text">
231 - <strong x-text="'Plugin installed: ' + ($store.pluginInstallStore.result?.title || $store.pluginInstallStore.result?.plugin_name || '')"></strong>
225 + <strong
226 + x-text="'Plugin installed: ' + ($store.pluginInstallStore.result?.title || $store.pluginInstallStore.result?.plugin_name || '')"></strong>
227 <div class="pi-result-path" x-text="$store.pluginInstallStore.result?.path || ''"></div>
228 </div>
229 </div>
@@ -236,7 +231,7 @@
231
232 <!-- ═══ ZIP Tab ═══ -->
233 <div x-show="$store.pluginInstallStore.activeTab === 'zip'"
239 - x-init="$store.pluginInstallStore.resetZip()">
234 + x-init="$store.pluginInstallStore.resetZip()">
235
236 <div class="pi-upload-section">
237 <label for="plugin-zip-file" class="pi-upload-btn button confirm"
@@ -252,8 +247,7 @@
247 </div>
248
249 <div x-show="$store.pluginInstallStore.zipFile" class="pi-actions-center">
255 - <button class="button confirm"
256 - @click="$store.pluginInstallStore.installZip()"
250 + <button class="button confirm" @click="$store.pluginInstallStore.installZip()"
251 :disabled="$store.pluginInstallStore.loading">
252 <span class="icon material-symbols-outlined">download</span> Install Plugin
253 </button>
@@ -272,7 +266,8 @@
266 <span class="material-symbols-outlined">check_circle</span>
267 </div>
268 <div class="pi-result-text">
275 - <strong x-text="'Plugin installed: ' + ($store.pluginInstallStore.result?.title || $store.pluginInstallStore.result?.plugin_name || '')"></strong>
269 + <strong
270 + x-text="'Plugin installed: ' + ($store.pluginInstallStore.result?.title || $store.pluginInstallStore.result?.plugin_name || '')"></strong>
271 <div class="pi-result-path" x-text="$store.pluginInstallStore.result?.path || ''"></div>
272 </div>
273 </div>
@@ -696,6 +691,7 @@
691 height: 164px;
692 }
693 }
694 +
695 /* ── Git Tab ─────────────────────────── */
696 .pi-form-group {
697 margin-bottom: 1rem;
@@ -775,7 +771,7 @@
771 .pi-error {
772 color: var(--color-error);
773 padding: 0.75rem;
778 - background: var(--color-error-bg, rgba(239,68,68,0.1));
774 + background: var(--color-error-bg, rgba(239, 68, 68, 0.1));
775 border-radius: 4px;
776 margin-top: 0.75rem;
777 }
@@ -797,8 +793,8 @@
793 align-items: center;
794 gap: 0.75rem;
795 padding: 0.75rem;
800 - background: rgba(34,197,94,0.1);
801 - border: 1px solid rgba(34,197,94,0.3);
796 + background: rgba(34, 197, 94, 0.1);
797 + border: 1px solid rgba(34, 197, 94, 0.3);
798 border-radius: 4px;
799 margin-top: 0.75rem;
800 }
@@ -822,4 +818,5 @@
818 }
819 </style>
820 </body>
825 -</html>
821 +
822 +</html>
\ No newline at end of file
plugins/plugin_installer/webui/pluginInstallStore.js
+36 -77
@@ -4,9 +4,9 @@ import { openModal } from "/js/modals.js";
4 import { marked } from "/vendor/marked/marked.esm.js";
5 import { toastFrontendSuccess } from "/components/notifications/notification-store.js";
6 import { showConfirmDialog } from "/js/confirmDialog.js";
7 -import "/components/plugins/list/pluginListStore.js";
8 -import "/components/plugins/list/plugin-init-store.js";
9 -import "/components/modals/image-viewer/image-viewer-store.js";
7 +import {store as pluginListStore } from "/components/plugins/list/pluginListStore.js";
8 +// import {store as pluginInitStore } from "/components/plugins/list/plugin-init-store.js";
9 +// import {store as imageViewerStore } from "/components/modals/image-viewer/image-viewer-store.js";
10
11 const PLUGIN_API = "plugins/plugin_installer/plugin_install";
12 const PER_PAGE = 20;
@@ -37,7 +37,7 @@ const model = {
37 gitToken: "",
38
39 // Index state
40 - index: null,
40 + index: { authors: {}, plugins: {} },
41 installedPlugins: [],
42 search: "",
43 page: 1,
@@ -57,11 +57,7 @@ const model = {
57 // Installed plugin detail (for manage buttons)
58 installedPluginInfo: null,
59
60 - // Detail hero thumbnail fallbacks
61 - detailPluginName: "",
62 - /** @type {string[]} */
63 - detailThumbnailSources: [],
64 - detailThumbnailIndex: 0,
60 + detailThumbnailUrl: null,
61
62 // Tab state
63 activeTab: "store",
@@ -85,8 +81,20 @@ const model = {
81 },
82
83 _pluginName(plugin) {
88 - const repoUrl = (plugin?.github || "").replace(/\.git$/, "");
89 - return repoUrl.split("/").pop() || plugin?.key || "";
84 + const githubUrl = (plugin?.github || "").trim().replace(/\.git$/i, "");
85 + const match = githubUrl.match(/github\.com\/([^/]+)\/([^/]+)/i);
86 + if (!match) return plugin?.key || "";
87 +
88 + const parts = [match[1], match[2]]
89 + .map((part) =>
90 + String(part)
91 + .replace(/[^0-9A-Za-z]+/g, "_")
92 + .replace(/_+/g, "_")
93 + .replace(/^_+|_+$/g, "")
94 + )
95 + .filter(Boolean);
96 +
97 + return parts.join("__") || plugin?.key || "";
98 },
99
100 _pluginPrimaryTag(plugin) {
@@ -213,7 +221,7 @@ const model = {
221 this.loading = true;
222 this.loadingMessage = "Loading plugin index...";
223 this.error = "";
216 - this.index = null;
224 + // this.index = null;
225
226 const data = await api.callJsonApi(PLUGIN_API, {
227 action: "fetch_index",
@@ -342,18 +350,15 @@ const model = {
350 },
351
352 openDetail(plugin) {
345 - const detailPlugin = plugin ? { ...plugin } : null;
346 - this.selectedPlugin = detailPlugin;
353 + this.selectedPlugin = {...plugin, name: this._pluginName(plugin) };
354 this.error = "";
355 this.installedPluginInfo = null;
356 this.readmeContent = null;
350 - this.detailPluginName = this._pluginName(detailPlugin);
351 - this.detailThumbnailSources = this.getDetailThumbnailSources(detailPlugin);
352 - this.detailThumbnailIndex = 0;
353 - if (detailPlugin?.installed) {
354 - this.fetchInstalledPluginInfo(this.detailPluginName);
357 + this.detailThumbnailUrl = this.getThumbnailUrl(this.selectedPlugin);
358 + if (this.selectedPlugin.installed) {
359 + this.fetchInstalledPluginInfo(this.selectedPlugin.name);
360 }
356 - this.fetchReadme(detailPlugin);
361 + this.fetchReadme(this.selectedPlugin);
362 openModal("/plugins/plugin_installer/webui/install-detail.html");
363 },
364
@@ -429,9 +434,7 @@ const model = {
434 ...(this.selectedPlugin || {}),
435 installed: true,
436 };
432 - this.detailPluginName = data.plugin_name;
433 - this.detailThumbnailSources = this.getDetailThumbnailSources(this.selectedPlugin);
434 - this.detailThumbnailIndex = 0;
437 + this.detailThumbnailUrl = this.getThumbnailUrl(this.selectedPlugin);
438 this.fetchInstalledPluginInfo(data.plugin_name);
439
440 toastFrontendSuccess(
@@ -462,13 +465,6 @@ const model = {
465 }
466 },
467
465 - _pluginListStore() {
466 - return getStore("pluginListStore");
467 - },
468 -
469 - _pluginInitStore() {
470 - return getStore("pluginInitStore");
471 - },
468
469 manageOpenPlugin() {
470 const info = this.installedPluginInfo;
@@ -477,23 +473,20 @@ const model = {
473 },
474
475 async manageOpenConfig() {
480 - const pls = this._pluginListStore();
481 - if (pls?.openPluginConfig && this.installedPluginInfo) {
482 - await pls.openPluginConfig(this.installedPluginInfo);
476 + if (this.installedPluginInfo) {
477 + await pluginListStore.openPluginConfig(this.installedPluginInfo);
478 }
479 },
480
481 async manageOpenDoc(doc) {
487 - const pls = this._pluginListStore();
488 - if (pls?.openPluginDoc && this.installedPluginInfo) {
489 - await pls.openPluginDoc(this.installedPluginInfo, doc);
482 + if (this.installedPluginInfo) {
483 + await pluginListStore.openPluginDoc(this.installedPluginInfo, doc);
484 }
485 },
486
487 manageOpenInfo() {
494 - const pls = this._pluginListStore();
495 - if (pls?.openPluginInfo && this.installedPluginInfo) {
496 - pls.openPluginInfo(this.installedPluginInfo);
488 + if (this.installedPluginInfo) {
489 + pluginListStore.openPluginInfo(this.installedPluginInfo);
490 }
491 },
492
@@ -505,9 +498,8 @@ const model = {
498 },
499
500 async manageDeletePlugin() {
508 - const pls = this._pluginListStore();
509 - if (pls?.deletePlugin && this.installedPluginInfo) {
510 - await pls.deletePlugin(this.installedPluginInfo);
501 + if (this.installedPluginInfo) {
502 + await pluginListStore.deletePlugin(this.installedPluginInfo);
503 const currentPlugin = this.selectedPlugin;
504 if (currentPlugin) {
505 this.selectedPlugin = { ...currentPlugin, installed: false };
@@ -531,41 +523,8 @@ const model = {
523 return rawBase ? `${rawBase}/main/thumbnail.png` : null;
524 },
525
534 - getLocalThumbnailUrl() {
535 - const name = this.detailPluginName;
536 - return name ? `/plugins/${name}/thumbnail.png` : null;
537 - },
538 -
539 - /**
540 - * Build the ordered thumbnail URLs for the detail view.
541 - * First try GitHub, then the local plugin asset.
542 - * @param {any} plugin
543 - * @returns {string[]}
544 - */
545 - getDetailThumbnailSources(plugin) {
546 - const currentPlugin = plugin || this.selectedPlugin;
547 - const rawSources = [
548 - this.getThumbnailUrl(currentPlugin),
549 - this.getLocalThumbnailUrl(),
550 - ];
551 - const uniqueSources = [];
552 - const seen = new Set();
553 -
554 - for (const url of rawSources) {
555 - if (typeof url !== "string" || seen.has(url)) continue;
556 - seen.add(url);
557 - uniqueSources.push(url);
558 - }
559 -
560 - return uniqueSources;
561 - },
562 -
526 getDetailThumbnailUrl() {
564 - return this.detailThumbnailSources[this.detailThumbnailIndex] || null;
565 - },
566 -
567 - nextDetailThumbnail() {
568 - this.detailThumbnailIndex += 1;
527 + return this.detailThumbnailUrl;
528 },
529
530 // ── Shared ───────────────────────────────────
@@ -593,7 +552,7 @@ const model = {
552
553 /** Called from x-destroy when the installer modal is torn down; refreshes the plugin list store */
554 refreshPluginList() {
596 - getStore("pluginListStore")?.refresh();
555 + pluginListStore.refresh();
556 },
557
558 truncate(text, max) {