plugins: thumbnails in plugin list and hub

Alessandro committed Mar 19, 2026 at 15:57 UTC 6aaa435a1d767a1cbec6dfe6b99349ceaa2425d5
6 files changed +182 -93
helpers/plugins.py
+9
@@ -99,6 +99,7 @@ class PluginListItem(BaseModel):
99 toggle_state: ToggleState = "disabled"
100 current_commit: str = ""
101 current_commit_timestamp: str = ""
102 + thumbnail_url: str = ""
103
104
105 class PluginUpdateInfo(BaseModel):
@@ -226,6 +227,13 @@ def get_enhanced_plugins_list(
227 has_license = files.exists(str(d / "LICENSE"))
228 has_execute_script = files.exists(str(d / "execute.py"))
229 toggle_state = get_toggle_state(d.name)
230 + thumbnail_url = ""
231 + _thumb_exts = ("png", "jpg", "jpeg", "gif", "webp")
232 + for _ext in _thumb_exts:
233 + _thumb = d / "webui" / f"thumbnail.{_ext}"
234 + if _thumb.is_file():
235 + thumbnail_url = f"/plugins/{d.name}/webui/thumbnail.{_ext}"
236 + break
237 current_commit = ""
238 current_commit_timestamp = ""
239 if is_custom:
@@ -253,6 +261,7 @@ def get_enhanced_plugins_list(
261 toggle_state=toggle_state,
262 current_commit=current_commit,
263 current_commit_timestamp=current_commit_timestamp,
264 + thumbnail_url=thumbnail_url,
265 )
266 )
267 except Exception as e:
plugins/_plugin_installer/api/plugin_install.py
+2 -1
@@ -46,10 +46,11 @@ class PluginInstall(ApiHandler):
46 git_url = (input.get("git_url", "") or "").strip()
47 git_token = (input.get("git_token", "") or "").strip() or None
48 plugin_name = input.get("plugin_name", "")
49 + thumbnail_url = (input.get("thumbnail_url") or "").strip()
50 if not git_url:
51 return {"success": False, "error": "Git URL is required"}
52
52 - return install_from_git(url=git_url, token=git_token, plugin_name=plugin_name)
53 + return install_from_git(url=git_url, token=git_token, plugin_name=plugin_name, thumbnail_url=thumbnail_url)
54
55 def _update_git(self, input: dict) -> dict:
56 return update_from_git(input.get("plugin_name", ""))
plugins/_plugin_installer/helpers/install.py
+61 -3
@@ -151,7 +151,31 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
151 pass
152
153
154 -def install_from_git(url: str, token: str | None = None, plugin_name: str = "") -> dict:
154 +def _download_thumbnail(thumbnail_url: str, plugin_dir: str) -> None:
155 + """Download thumbnail from URL to plugin_dir/webui/thumbnail.<ext>. Non-fatal."""
156 + try:
157 + if not thumbnail_url:
158 + return
159 + from urllib.parse import urlparse
160 + parsed = urlparse(thumbnail_url)
161 + if parsed.scheme not in ("http", "https"):
162 + return
163 + _allowed_exts = {"png", "jpg", "jpeg", "gif", "webp"}
164 + url_path = parsed.path.lower()
165 + ext = url_path.rsplit(".", 1)[-1] if "." in url_path else ""
166 + if ext not in _allowed_exts:
167 + ext = "png"
168 + webui_dir = Path(plugin_dir) / "webui"
169 + webui_dir.mkdir(parents=True, exist_ok=True)
170 + dest = webui_dir / f"thumbnail.{ext}"
171 + req = urllib.request.Request(thumbnail_url, headers={"User-Agent": "AgentZero"})
172 + with urllib.request.urlopen(req, timeout=10) as resp:
173 + dest.write_bytes(resp.read())
174 + except Exception as e:
175 + print_style.PrintStyle.warning(f"Failed to download plugin thumbnail: {e}")
176 +
177 +
178 +def install_from_git(url: str, token: str | None = None, plugin_name: str = "", thumbnail_url: str = "") -> dict:
179 """Clone git repo into usr/plugins/, validate plugin.yaml.
180 Returns dict with plugin name and metadata."""
181 from helpers.git import clone_repo
@@ -173,6 +197,8 @@ def install_from_git(url: str, token: str | None = None, plugin_name: str = "")
197 files.delete_dir(git_dir)
198 raise
199
200 + _download_thumbnail(thumbnail_url, final_dir)
201 +
202 # run installation hook
203 try:
204 run_install_hook(plugin_name)
@@ -253,13 +279,45 @@ def get_plugin_hub_index() -> dict[str, Any]:
279 if not isinstance(plugins, dict):
280 raise ValueError("Plugin index payload is missing a valid 'plugins' map")
281
282 + from helpers.plugins import find_plugin_dir
283 +
284 installed_dirs = set(get_plugins_list())
285 installed_keys: list[str] = []
286 + _thumb_exts = ("png", "jpg", "jpeg", "gif", "webp")
287 +
288 for key, plugin_data in plugins.items():
289 if not isinstance(plugin_data, dict):
290 continue
261 - if key in installed_dirs:
262 - installed_keys.append(key)
291 + if key not in installed_dirs:
292 + continue
293 + installed_keys.append(key)
294 +
295 + # Backfill thumbnail for plugins installed before this feature existed
296 + plugin_dir = find_plugin_dir(key)
297 + if not plugin_dir:
298 + continue
299 + webui_dir = Path(plugin_dir) / "webui"
300 + has_thumb = any((webui_dir / f"thumbnail.{ext}").is_file() for ext in _thumb_exts)
301 + if has_thumb:
302 + continue
303 + thumb_url = plugin_data.get("thumbnail") or ""
304 + if not thumb_url:
305 + from urllib.parse import urlparse
306 + raw_base = None
307 + github = plugin_data.get("github") or ""
308 + if github:
309 + try:
310 + parsed = urlparse(github)
311 + if parsed.netloc == "github.com":
312 + parts = parsed.path.strip("/").split("/")
313 + if len(parts) >= 2:
314 + raw_base = f"https://raw.githubusercontent.com/{parts[0]}/{parts[1]}"
315 + except Exception:
316 + pass
317 + if raw_base:
318 + thumb_url = f"{raw_base}/main/thumbnail.png"
319 + if thumb_url:
320 + _download_thumbnail(thumb_url, plugin_dir)
321
322 return {"index": index_data, "installed_plugins": installed_keys}
323
plugins/_plugin_installer/webui/install-index.html
+93 -89
@@ -69,37 +69,43 @@
69 class="pi-grid">
70 <template x-for="plugin in $store.pluginInstallStore.paginatedPlugins" :key="plugin.key">
71 <button type="button" class="pi-card" @click="$store.pluginInstallStore.openDetail(plugin)">
72 - <div class="pi-card-thumb">
73 - <template x-if="plugin.thumbnail">
74 - <img :src="plugin.thumbnail" :alt="plugin.title" loading="lazy">
75 - </template>
76 - <template x-if="!plugin.thumbnail">
77 - <span class="material-symbols-outlined pi-card-placeholder">extension</span>
78 - </template>
79 -
80 - <template x-if="plugin.installed">
81 - <span class="pi-card-installed-pill">Installed</span>
82 - </template>
72 + <div class="pi-card-header">
73 + <div class="pi-card-thumb">
74 + <template x-if="plugin.thumbnail">
75 + <img :src="plugin.thumbnail" :alt="plugin.title" loading="lazy">
76 + </template>
77 + <template x-if="!plugin.thumbnail">
78 + <span class="material-symbols-outlined pi-card-placeholder">extension</span>
79 + </template>
80 + </div>
81
84 - <template x-if="plugin.has_update">
85 - <span class="pi-card-update-pill" :class="{ 'pi-card-update-pill-offset': plugin.installed }">New version</span>
86 - </template>
87 - </div>
82 + <div class="pi-card-heading">
83 + <h3 class="pi-card-title" x-text="plugin.title || plugin.key"></h3>
84 +
85 + <div class="pi-card-subtitle"
86 + x-text="$store.pluginInstallStore.getBrowseSubtitle(plugin)">
87 + </div>
88
89 - <div class="pi-card-body">
90 - <div class="pi-card-head">
91 - <div class="pi-card-heading">
92 - <h3 class="pi-card-title" x-text="plugin.title || plugin.key"></h3>
93 - <div class="pi-card-subtitle"
94 - x-text="$store.pluginInstallStore.getBrowseSubtitle(plugin)">
95 - </div>
89 + <div class="pi-card-pills">
90 + <template x-if="plugin.installed">
91 + <span class="pi-card-installed-pill">Installed</span>
92 + </template>
93 + <template x-if="plugin.has_update">
94 + <span class="pi-card-update-pill">New version</span>
95 + </template>
96 </div>
97 - <span class="material-symbols-outlined pi-card-arrow">arrow_outward</span>
97 +
98 </div>
99
100 + <span class="material-symbols-outlined pi-card-arrow">arrow_outward</span>
101 + </div>
102 +
103 + <div class="pi-card-body">
104 +
105 <div class="pi-card-desc"
106 x-text="$store.pluginInstallStore.truncate(plugin.description, 110)">
107 </div>
108 +
109 </div>
110
111 <div class="pi-card-footer">
@@ -403,17 +409,24 @@
409 box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
410 }
411
412 + .pi-card-header {
413 + display: flex;
414 + align-items: center;
415 + gap: 0.75rem;
416 + padding: 0.85rem;
417 + }
418 +
419 .pi-card-thumb {
407 - position: relative;
420 + width: 56px;
421 + height: 56px;
422 + flex-shrink: 0;
423 display: flex;
424 align-items: center;
425 justify-content: center;
411 - height: 148px;
412 - padding: 0.5rem;
413 - background:
414 - radial-gradient(circle at top, #6b728089, var(--color-panel)),
415 - var(--color-panel);
416 - border-bottom: 1px solid var(--color-border);
426 + border-radius: 0.5rem;
427 + overflow: hidden;
428 + background: var(--color-panel);
429 + border: 1px solid var(--color-border);
430 }
431
432 .pi-card-thumb img {
@@ -423,65 +436,13 @@
436 }
437
438 .pi-card-placeholder {
426 - font-size: 4rem;
439 + font-size: 2rem;
440 color: var(--color-text-muted);
441 }
442
430 - .pi-card-installed-pill {
431 - position: absolute;
432 - top: 0.75rem;
433 - right: 0.75rem;
434 - padding: 0.24rem 0.5rem;
435 - border-radius: 0.5rem;
436 - background: rgba(34, 197, 94, 0.15);
437 - color: #4ade80;
438 - font-size: 0.72rem;
439 - font-weight: 700;
440 - }
441 -
442 - body.light-mode .pi-card-installed-pill {
443 - background: rgba(34, 197, 94, 0.22);
444 - color: #166534;
445 - }
446 -
447 - .pi-card-update-pill {
448 - position: absolute;
449 - top: 0.75rem;
450 - right: 0.75rem;
451 - padding: 0.24rem 0.5rem;
452 - border-radius: 0.5rem;
453 - background: rgba(59, 130, 246, 0.16);
454 - color: #60a5fa;
455 - font-size: 0.72rem;
456 - font-weight: 700;
457 - }
458 -
459 - body.light-mode .pi-card-update-pill {
460 - background: rgba(59, 130, 246, 0.2);
461 - color: #1d4ed8;
462 - }
463 -
464 - .pi-card-update-pill-offset {
465 - top: 2.35rem;
466 - }
467 -
468 - .pi-card-body {
469 - flex: 1;
470 - display: flex;
471 - flex-direction: column;
472 - gap: 0.8rem;
473 - padding: 1rem;
474 - }
475 -
476 - .pi-card-head {
477 - display: flex;
478 - align-items: flex-start;
479 - justify-content: space-between;
480 - gap: 0.75rem;
481 - }
482 -
443 .pi-card-heading {
444 min-width: 0;
445 + flex: 1;
446 }
447
448 .pi-card-title {
@@ -492,7 +453,7 @@
453 }
454
455 .pi-card-subtitle {
495 - margin-top: 0.25rem;
456 + margin-top: 0.2rem;
457 font-size: 0.82rem;
458 color: var(--color-text-muted);
459 white-space: nowrap;
@@ -506,12 +467,59 @@
467 flex-shrink: 0;
468 }
469
470 + .pi-card-body {
471 + flex: 1;
472 + display: flex;
473 + flex-direction: column;
474 + gap: 0.6rem;
475 + padding: 0 0.85rem 0.85rem;
476 + }
477 +
478 .pi-card-desc {
479 font-size: 0.88rem;
480 line-height: 1.5;
481 color: var(--color-text-secondary);
482 }
483
484 + .pi-card-pills {
485 + display: flex;
486 + flex-wrap: wrap;
487 + padding: var(--spacing-xs) 0;
488 + gap: 0.4rem;
489 + }
490 +
491 + .pi-card-pills
492 +
493 + .pi-card-installed-pill {
494 + display: inline-flex;
495 + padding: 0.24rem 0.5rem;
496 + border-radius: 0.5rem;
497 + background: rgba(34, 197, 94, 0.15);
498 + color: #4ade80;
499 + font-size: 0.72rem;
500 + font-weight: 700;
501 + }
502 +
503 + body.light-mode .pi-card-installed-pill {
504 + background: rgba(34, 197, 94, 0.22);
505 + color: #166534;
506 + }
507 +
508 + .pi-card-update-pill {
509 + display: inline-flex;
510 + padding: 0.24rem 0.5rem;
511 + border-radius: 0.5rem;
512 + background: rgba(59, 130, 246, 0.16);
513 + color: #60a5fa;
514 + font-size: 0.72rem;
515 + font-weight: 700;
516 + }
517 +
518 + body.light-mode .pi-card-update-pill {
519 + background: rgba(59, 130, 246, 0.2);
520 + color: #1d4ed8;
521 + }
522 +
523 .pi-card-footer {
524 display: flex;
525 align-items: center;
@@ -575,10 +583,6 @@
583 .pi-grid {
584 grid-template-columns: 1fr;
585 }
578 -
579 - .pi-card-thumb {
580 - height: 164px;
581 - }
586 }
587 </style>
588 </body>
plugins/_plugin_installer/webui/pluginInstallStore.js
+1
@@ -515,6 +515,7 @@ const model = {
515 action: "install_git",
516 git_url: plugin.github,
517 plugin_name: plugin.key,
518 + thumbnail_url: this.getThumbnailUrl(plugin) || "",
519 });
520
521 if (!data.success) {
webui/components/plugins/list/plugin-list.html
+16
@@ -86,6 +86,12 @@
86 }))" :key="plugin.path || plugin.name">
87 <div class="plugin-card">
88 <div class="plugin-header">
89 + <template x-if="plugin.thumbnail_url">
90 + <img class="plugin-thumb"
91 + :src="plugin.thumbnail_url"
92 + :alt="plugin.display_name || plugin.name"
93 + loading="lazy">
94 + </template>
95 <div class="plugin-heading">
96 <div class="plugin-title" x-text="plugin.display_name || plugin.name || '(unnamed plugin)'"></div>
97 <code class="plugin-path" x-text="plugin.path"></code>
@@ -307,6 +313,16 @@
313 flex: 1 1 auto;
314 }
315
316 + .plugin-thumb {
317 + width: 44px;
318 + height: 44px;
319 + border-radius: 0.5rem;
320 + object-fit: contain;
321 + flex-shrink: 0;
322 + background: var(--color-panel);
323 + border: 1px solid var(--color-border);
324 + }
325 +
326 .plugin-title {
327 font-weight: 600;
328 font-size: 1rem;