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;