feat: add built-in plugin discovery cards to the welcome screen

Add the always-enabled `_discovery` plugin to turn the welcome screen into a discovery surface for the Plugin Hub and A0 integrations.

Includes a hero card plus Telegram, Email, and WhatsApp feature cards, with persistent dismiss/restore state, CTA routing to plugin config screens, and self-contained placeholder artwork. Implemented entirely through the existing WebUI extension mechanism with no core welcome-screen changes.

stores cleanup

layout polish and onboarding integration

Move feature card titles beside thumbnails for better space efficiency
and visibility. Restructure card markup and styles to support a fluid
grid layout and horizontal alignment.

Integrate discovery cards into the final onboarding step via a new
'onboarding-success-end' extension point, ensuring new users see
extension opportunities immediately after setup.

Hide discovery cards on the dashboard while the missing API key
onboarding banner is visible to reduce UI noise and user confusion during initial config.

update discovery card initialization and loading logic

Enhance the discovery store to fetch cards from the API, improving the dynamic loading of discovery cards based on user context. This change optimizes the user experience by ensuring relevant cards are displayed immediately after onboarding and when modals are closed.

And on top of that, there's a proper backend for these new cards.
This commit is contained in:
Alessandro 2026-03-31 18:28:28 +02:00
parent 0bbc657dd3
commit 0061b3a511
13 changed files with 1217 additions and 15 deletions

View file

@ -0,0 +1,77 @@
from helpers.extension import Extension
from helpers import plugins
class DiscoveryCardsExtension(Extension):
"""Injects discovery cards into the banners list."""
async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
# Optional logic: only show specific cards if plugins aren't already configured.
# Telegram, Email, Whatsapp are built-in, so we only need to check if they've been configured.
telegram_config = plugins.get_plugin_config("_telegram_integration") or {}
email_config = plugins.get_plugin_config("_email_integration") or {}
whatsapp_config = plugins.get_plugin_config("_whatsapp_integration") or {}
# 1. Plugin Hub Hero
banners.append({
"id": "discovery-plugin-hub",
"type": "hero",
"title": "Discover the Plugin Hub",
"description": "Extend Agent Zero with integrations, tools, and automations from the community.",
"thumbnail": "/plugins/_discovery/webui/assets/hero-plugin-hub.png",
"icon": "extension",
"cta_text": "Explore Plugins",
"cta_action": "open-plugin-hub",
"dismissible": True,
"priority": 100,
"show_in_onboarding": True
})
# 2. Telegram
if not telegram_config.get("bot_token"):
banners.append({
"id": "discovery-telegram",
"type": "feature",
"title": "Connect Telegram",
"description": "Chat with Agent Zero from Telegram wherever you are.",
"thumbnail": "/plugins/_discovery/webui/assets/thumb-telegram.png",
"icon": "send",
"cta_text": "Setup",
"cta_action": "open-plugin-config:_telegram_integration",
"dismissible": True,
"priority": 50,
"show_in_onboarding": True
})
# 3. Email
if not email_config.get("imap_username") and not email_config.get("smtp_username"):
banners.append({
"id": "discovery-email",
"type": "feature",
"title": "Setup Email",
"description": "Let Agent Zero read and send emails on your behalf.",
"thumbnail": "/plugins/_discovery/webui/assets/thumb-email.png",
"icon": "mail",
"cta_text": "Setup",
"cta_action": "open-plugin-config:_email_integration",
"dismissible": True,
"priority": 50,
"show_in_onboarding": True
})
# 4. WhatsApp
if not whatsapp_config.get("phone_number_id"):
banners.append({
"id": "discovery-whatsapp",
"type": "feature",
"title": "Connect WhatsApp",
"description": "Send and receive WhatsApp messages through A0.",
"thumbnail": "/plugins/_discovery/webui/assets/thumb-whatsapp.png",
"icon": "chat",
"cta_text": "Setup",
"cta_action": "open-plugin-config:_whatsapp_integration",
"dismissible": True,
"priority": 50,
"show_in_onboarding": True
})

View file

@ -0,0 +1,483 @@
<script type="module">
import { store } from "/plugins/_discovery/webui/discovery-store.js";
</script>
<div x-data>
<template x-if="$store.discoveryStore">
<div class="discovery-slot"
@modal-closed.window="$store.discoveryStore.refreshCards()"
x-create="$store.discoveryStore.refreshCards()"
x-show="$store.discoveryStore.cards.length > 0 || $store.discoveryStore.hasDismissedCards">
<div class="discovery-section" style="margin-top: 2rem;">
<div class="discovery-features" x-show="$store.discoveryStore.featureCards.length > 0" style="text-align: left;">
<template x-for="card in $store.discoveryStore.featureCards" :key="card.id">
<article class="discovery-feature-card"
role="button"
tabindex="0"
@click="$store.discoveryStore.executeCta(card.cta_action)"
@keydown.enter.prevent="$store.discoveryStore.executeCta(card.cta_action)"
@keydown.space.prevent="$store.discoveryStore.executeCta(card.cta_action)">
<button class="discovery-dismiss discovery-dismiss-small"
type="button"
x-show="card.dismissible"
@click.stop="$store.discoveryStore.dismissCard(card.id)"
:aria-label="`Dismiss ${card.title}`"
title="Dismiss">
<span class="material-symbols-outlined">close</span>
</button>
<div class="discovery-feature-head">
<div class="discovery-feature-thumb">
<template x-if="card.thumbnail">
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
</template>
<template x-if="!card.thumbnail && card.icon">
<span class="material-symbols-outlined discovery-feature-icon" x-text="card.icon"></span>
</template>
</div>
<h4 class="discovery-feature-title" x-text="card.title"></h4>
</div>
<p class="discovery-feature-desc" x-text="card.description"></p>
<button class="btn btn primary"
type="button"
@click.stop="$store.discoveryStore.executeCta(card.cta_action)">
<span x-text="card.cta_text"></span>
<span class="material-symbols-outlined">arrow_forward</span>
</button>
</article>
</template>
</div>
<template x-for="card in $store.discoveryStore.heroCards" :key="card.id">
<article class="discovery-hero"
role="button"
tabindex="0"
@click="$store.discoveryStore.executeCta(card.cta_action)"
@keydown.enter.prevent="$store.discoveryStore.executeCta(card.cta_action)"
@keydown.space.prevent="$store.discoveryStore.executeCta(card.cta_action)">
<button class="discovery-dismiss"
type="button"
x-show="card.dismissible"
@click.stop="$store.discoveryStore.dismissCard(card.id)"
:aria-label="`Dismiss ${card.title}`"
title="Dismiss">
<span class="material-symbols-outlined">close</span>
</button>
<div class="discovery-hero-content">
<h3 class="discovery-hero-title" x-text="card.title"></h3>
<p class="discovery-hero-desc" x-text="card.description"></p>
<button class="btn btn-ok"
type="button"
@click.stop="$store.discoveryStore.executeCta(card.cta_action)">
<span x-text="card.cta_text"></span>
<span class="material-symbols-outlined">arrow_forward</span>
</button>
</div>
<div class="discovery-hero-thumb">
<template x-if="card.thumbnail">
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
</template>
<template x-if="!card.thumbnail && card.icon">
<span class="material-symbols-outlined discovery-hero-icon" x-text="card.icon"></span>
</template>
</div>
</article>
</template>
<div class="discovery-undismiss" x-show="$store.discoveryStore.hasDismissedCards">
<button type="button"
class="discovery-undismiss-btn"
@click="$store.discoveryStore.undismissCards()">
<span class="material-symbols-outlined">undo</span>
<span>Show dismissed suggestions</span>
</button>
</div>
</div>
</div>
</template>
<style>
.discovery-slot {
grid-column: 1 / -1;
width: 100%;
}
.discovery-section {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
margin-top: 0.1rem;
}
.discovery-hero,
.discovery-feature-card {
position: relative;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-panel);
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.discovery-hero:hover,
.discovery-feature-card:hover,
.discovery-hero:focus-visible,
.discovery-feature-card:focus-visible {
border-color: color-mix(in srgb, var(--color-primary) 72%, white 6%);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
outline: none;
}
.discovery-hero {
display: flex;
align-items: center;
gap: 1rem;
min-height: 152px;
overflow: hidden;
padding: 1.25rem 1.25rem 1.25rem 1.5rem;
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--color-primary) 10%, transparent),
transparent 58%
),
radial-gradient(
circle at 92% 24%,
color-mix(in srgb, var(--color-primary) 12%, transparent),
transparent 42%
),
var(--color-panel);
}
.discovery-hero-content,
.discovery-hero-thumb {
position: relative;
z-index: 1;
}
.discovery-hero-content {
flex: 1 1 auto;
min-width: 0;
}
.discovery-hero-title {
margin: 0 0 0.4rem;
color: var(--color-text);
font-size: 1.08rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.discovery-hero-desc {
margin: 0 0 1rem;
color: var(--color-text);
font-size: 0.86rem;
line-height: 1.5;
max-width: 38ch;
}
.discovery-cta-link,
.discovery-undismiss-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
cursor: pointer;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
}
.discovery-hero-thumb {
flex: 0 0 132px;
width: 132px;
height: 108px;
display: flex;
align-items: center;
justify-content: center;
}
.discovery-hero-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.12));
}
.discovery-hero-icon {
font-size: 3rem;
color: var(--color-highlight-dark);
}
.discovery-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.discovery-feature-card {
display: flex;
flex-direction: column;
align-items: flex-start;
min-height: 188px;
padding: 1rem;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary) 4%, transparent),
transparent 38%
),
var(--color-panel);
}
.discovery-feature-head {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
.discovery-feature-thumb {
flex: 0 0 auto;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
overflow: hidden;
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.discovery-feature-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.discovery-feature-icon {
font-size: 1.45rem;
color: var(--color-highlight-dark);
}
.discovery-feature-title {
margin: 0;
flex: 1 1 auto;
min-width: 0;
color: var(--color-text);
font-size: 0.92rem;
font-weight: 600;
line-height: 1.25;
}
.discovery-feature-desc {
margin: 0.55rem 0 0;
padding-bottom: var(--spacing-md);
color: var(--color-text);
font-size: 0.8rem;
line-height: 1.45;
}
.discovery-feature-card > .btn {
margin-top: auto;
}
.discovery-cta-link {
margin-top: 0.95rem;
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
border-radius: 8px;
background: transparent;
color: var(--color-primary);
padding: 0.42rem 0.72rem;
font-size: 0.79rem;
font-weight: 600;
}
.discovery-cta-link:hover,
.discovery-cta-link:focus-visible {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
outline: none;
}
.discovery-dismiss {
position: absolute;
top: 0.65rem;
right: 0.65rem;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-secondary);
cursor: pointer;
opacity: 0;
transition:
opacity 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
z-index: 2;
}
.discovery-dismiss-small {
top: 0.55rem;
right: 0.55rem;
width: 26px;
height: 26px;
}
.discovery-hero:hover .discovery-dismiss,
.discovery-hero:focus-visible .discovery-dismiss,
.discovery-feature-card:hover .discovery-dismiss,
.discovery-feature-card:focus-visible .discovery-dismiss {
opacity: 0.68;
}
.discovery-dismiss:hover,
.discovery-dismiss:focus-visible {
opacity: 1;
color: var(--color-text);
background: color-mix(in srgb, var(--color-border) 84%, transparent);
outline: none;
}
.discovery-dismiss .material-symbols-outlined {
font-size: 1rem;
}
.discovery-undismiss {
display: flex;
justify-content: flex-start;
}
.discovery-undismiss-btn {
border: none;
background: transparent;
padding: 0;
color: var(--color-secondary);
font-size: 0.8rem;
font-weight: 500;
}
.discovery-undismiss-btn:hover,
.discovery-undismiss-btn:focus-visible {
color: var(--color-primary);
outline: none;
}
.discovery-cta-link .material-symbols-outlined,
.discovery-undismiss-btn .material-symbols-outlined {
font-size: 1rem;
}
@media (hover: none) {
.discovery-dismiss {
opacity: 0.68;
}
}
@media (max-width: 768px) {
.discovery-section {
gap: 0.85rem;
}
.discovery-hero {
min-height: 0;
padding: 1rem;
}
.discovery-hero-thumb {
flex-basis: 92px;
width: 92px;
height: 76px;
}
.discovery-features {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.discovery-feature-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
min-height: 0;
align-items: center;
padding: 0.85rem 1rem;
}
.discovery-feature-head {
grid-column: 1;
min-width: 0;
}
.discovery-feature-thumb {
width: 46px;
height: 46px;
}
.discovery-feature-desc {
display: none;
}
.discovery-feature-card > .btn {
grid-column: 2;
grid-row: 1;
align-self: center;
margin-top: 0;
}
.discovery-cta-link {
margin-top: 0;
}
}
@media (max-width: 520px) {
.discovery-slot {
margin-top: 0.15rem;
}
.discovery-hero {
padding-right: 0.95rem;
}
.discovery-hero-thumb {
display: none;
}
.discovery-hero-title {
font-size: 1rem;
}
.discovery-feature-card {
grid-template-columns: minmax(0, 1fr) auto;
padding-right: 3rem;
}
.discovery-feature-card > .btn {
justify-self: end;
}
}
</style>
</div>

View file

@ -0,0 +1,481 @@
<script type="module">
import { store } from "/plugins/_discovery/webui/discovery-store.js";
</script>
<div x-data>
<template x-if="$store.discoveryStore">
<div class="discovery-slot"
@modal-closed.window="$store.discoveryStore.refreshCards()"
x-create="$store.discoveryStore.refreshCards()"
x-show="!($store.welcomeStore?.banners || []).some(b => b.id === 'missing-api-key') && ($store.discoveryStore.cards.length > 0 || $store.discoveryStore.hasDismissedCards)">
<div class="discovery-section">
<template x-for="card in $store.discoveryStore.heroCards" :key="card.id">
<article class="discovery-hero"
role="button"
tabindex="0"
@click="$store.discoveryStore.executeCta(card.cta_action)"
@keydown.enter.prevent="$store.discoveryStore.executeCta(card.cta_action)"
@keydown.space.prevent="$store.discoveryStore.executeCta(card.cta_action)">
<button class="discovery-dismiss"
type="button"
x-show="card.dismissible"
@click.stop="$store.discoveryStore.dismissCard(card.id)"
:aria-label="`Dismiss ${card.title}`"
title="Dismiss">
<span class="material-symbols-outlined">close</span>
</button>
<div class="discovery-hero-content">
<h3 class="discovery-hero-title" x-text="card.title"></h3>
<p class="discovery-hero-desc" x-text="card.description"></p>
<button class="btn btn-ok"
type="button"
@click.stop="$store.discoveryStore.executeCta(card.cta_action)">
<span x-text="card.cta_text"></span>
<span class="material-symbols-outlined">arrow_forward</span>
</button>
</div>
<div class="discovery-hero-thumb">
<template x-if="card.thumbnail">
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
</template>
<template x-if="!card.thumbnail && card.icon">
<span class="material-symbols-outlined discovery-hero-icon" x-text="card.icon"></span>
</template>
</div>
</article>
</template>
<div class="discovery-features" x-show="$store.discoveryStore.featureCards.length > 0">
<template x-for="card in $store.discoveryStore.featureCards" :key="card.id">
<article class="discovery-feature-card"
role="button"
tabindex="0"
@click="$store.discoveryStore.executeCta(card.cta_action)"
@keydown.enter.prevent="$store.discoveryStore.executeCta(card.cta_action)"
@keydown.space.prevent="$store.discoveryStore.executeCta(card.cta_action)">
<button class="discovery-dismiss discovery-dismiss-small"
type="button"
x-show="card.dismissible"
@click.stop="$store.discoveryStore.dismissCard(card.id)"
:aria-label="`Dismiss ${card.title}`"
title="Dismiss">
<span class="material-symbols-outlined">close</span>
</button>
<div class="discovery-feature-head">
<div class="discovery-feature-thumb">
<template x-if="card.thumbnail">
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
</template>
<template x-if="!card.thumbnail && card.icon">
<span class="material-symbols-outlined discovery-feature-icon" x-text="card.icon"></span>
</template>
</div>
<h4 class="discovery-feature-title" x-text="card.title"></h4>
</div>
<p class="discovery-feature-desc" x-text="card.description"></p>
<button class="btn btn primary"
type="button"
@click.stop="$store.discoveryStore.executeCta(card.cta_action)">
<span x-text="card.cta_text"></span>
<span class="material-symbols-outlined">arrow_forward</span>
</button>
</article>
</template>
</div>
<div class="discovery-undismiss" x-show="$store.discoveryStore.hasDismissedCards">
<button type="button"
class="discovery-undismiss-btn"
@click="$store.discoveryStore.undismissCards()">
<span class="material-symbols-outlined">undo</span>
<span>Show dismissed suggestions</span>
</button>
</div>
</div>
</div>
</template>
<style>
.discovery-slot {
grid-column: 1 / -1;
width: 100%;
}
.discovery-section {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
margin-top: 0.1rem;
}
.discovery-hero,
.discovery-feature-card {
position: relative;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-panel);
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.discovery-hero:hover,
.discovery-feature-card:hover,
.discovery-hero:focus-visible,
.discovery-feature-card:focus-visible {
border-color: color-mix(in srgb, var(--color-primary) 72%, white 6%);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
outline: none;
}
.discovery-hero {
display: flex;
align-items: center;
gap: 1rem;
min-height: 152px;
overflow: hidden;
padding: 1.25rem 1.25rem 1.25rem 1.5rem;
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--color-primary) 10%, transparent),
transparent 58%
),
radial-gradient(
circle at 92% 24%,
color-mix(in srgb, var(--color-primary) 12%, transparent),
transparent 42%
),
var(--color-panel);
}
.discovery-hero-content,
.discovery-hero-thumb {
position: relative;
z-index: 1;
}
.discovery-hero-content {
flex: 1 1 auto;
min-width: 0;
}
.discovery-hero-title {
margin: 0 0 0.4rem;
color: var(--color-text);
font-size: 1.08rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.discovery-hero-desc {
margin: 0 0 1rem;
color: var(--color-text);
font-size: 0.86rem;
line-height: 1.5;
max-width: 38ch;
}
.discovery-cta-link,
.discovery-undismiss-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
cursor: pointer;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
}
.discovery-hero-thumb {
flex: 0 0 132px;
width: 132px;
height: 108px;
display: flex;
align-items: center;
justify-content: center;
}
.discovery-hero-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.12));
}
.discovery-hero-icon {
font-size: 3rem;
color: var(--color-highlight-dark);
}
.discovery-features {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.discovery-feature-card {
display: flex;
flex-direction: column;
align-items: flex-start;
min-height: 188px;
padding: 1rem;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary) 4%, transparent),
transparent 38%
),
var(--color-panel);
}
.discovery-feature-head {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
.discovery-feature-thumb {
flex: 0 0 auto;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
overflow: hidden;
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.discovery-feature-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.discovery-feature-icon {
font-size: 1.45rem;
color: var(--color-highlight-dark);
}
.discovery-feature-title {
margin: 0;
flex: 1 1 auto;
min-width: 0;
color: var(--color-text);
font-size: 0.92rem;
font-weight: 600;
line-height: 1.25;
}
.discovery-feature-desc {
margin: 0.55rem 0 0;
padding-bottom: var(--spacing-md);
color: var(--color-text);
font-size: 0.8rem;
line-height: 1.45;
}
.discovery-feature-card > .btn {
margin-top: auto;
}
.discovery-cta-link {
margin-top: 0.95rem;
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
border-radius: 8px;
background: transparent;
color: var(--color-primary);
padding: 0.42rem 0.72rem;
font-size: 0.79rem;
font-weight: 600;
}
.discovery-cta-link:hover,
.discovery-cta-link:focus-visible {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
outline: none;
}
.discovery-dismiss {
position: absolute;
top: 0.65rem;
right: 0.65rem;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-secondary);
cursor: pointer;
opacity: 0;
transition:
opacity 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
z-index: 2;
}
.discovery-dismiss-small {
top: 0.55rem;
right: 0.55rem;
width: 26px;
height: 26px;
}
.discovery-hero:hover .discovery-dismiss,
.discovery-hero:focus-visible .discovery-dismiss,
.discovery-feature-card:hover .discovery-dismiss,
.discovery-feature-card:focus-visible .discovery-dismiss {
opacity: 0.68;
}
.discovery-dismiss:hover,
.discovery-dismiss:focus-visible {
opacity: 1;
color: var(--color-text);
background: color-mix(in srgb, var(--color-border) 84%, transparent);
outline: none;
}
.discovery-dismiss .material-symbols-outlined {
font-size: 1rem;
}
.discovery-undismiss {
display: flex;
justify-content: flex-start;
}
.discovery-undismiss-btn {
border: none;
background: transparent;
padding: 0;
color: var(--color-secondary);
font-size: 0.8rem;
font-weight: 500;
}
.discovery-undismiss-btn:hover,
.discovery-undismiss-btn:focus-visible {
color: var(--color-primary);
outline: none;
}
.discovery-cta-link .material-symbols-outlined,
.discovery-undismiss-btn .material-symbols-outlined {
font-size: 1rem;
}
@media (hover: none) {
.discovery-dismiss {
opacity: 0.68;
}
}
@media (max-width: 768px) {
.discovery-section {
gap: 0.85rem;
}
.discovery-hero {
min-height: 0;
padding: 1rem;
}
.discovery-hero-thumb {
flex-basis: 92px;
width: 92px;
height: 76px;
}
.discovery-features {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.discovery-feature-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
min-height: 0;
align-items: center;
padding: 0.85rem 1rem;
}
.discovery-feature-head {
grid-column: 1;
min-width: 0;
}
.discovery-feature-thumb {
width: 46px;
height: 46px;
}
.discovery-feature-desc {
display: none;
}
.discovery-feature-card > .btn {
grid-column: 2;
grid-row: 1;
align-self: center;
margin-top: 0;
}
.discovery-cta-link {
margin-top: 0;
}
}
@media (max-width: 520px) {
.discovery-slot {
margin-top: 0.15rem;
}
.discovery-hero {
padding-right: 0.95rem;
}
.discovery-hero-thumb {
display: none;
}
.discovery-hero-title {
font-size: 1rem;
}
.discovery-feature-card {
grid-template-columns: minmax(0, 1fr) auto;
padding-right: 3rem;
}
.discovery-feature-card > .btn {
justify-self: end;
}
}
</style>
</div>

View file

@ -0,0 +1,8 @@
name: _discovery
title: Plugin Discovery
description: Contextual discovery cards on the welcome screen promoting plugins and integrations.
version: 1.0.0
settings_sections: []
always_enabled: true
per_project_config: false
per_agent_config: false

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View file

@ -0,0 +1,129 @@
import { createStore } from "/js/AlpineStore.js";
import { toastFrontendError } from "/components/notifications/notification-store.js";
import { store as pluginListStore } from "/components/plugins/list/pluginListStore.js";
import * as API from "/js/api.js";
const STORAGE_KEY = "dismissed_discovery_cards";
const model = {
// --- State ---
/** @type {any[]} */
cards: [],
hasDismissedCards: false,
_initialized: false,
_isLoading: false,
// --- Lifecycle ---
init() {
if (this._initialized) return;
this._initialized = true;
},
// --- Actions ---
async refreshCards() {
if (this._isLoading) return;
this._isLoading = true;
try {
const response = await API.callJsonApi("/banners", {
banners: [],
context: {
is_onboarding: document.body.dataset.mode === "onboarding"
},
});
const banners = response?.banners || [];
const dismissed = this._getDismissedIds();
// Filter out standard banners, keep only hero and feature
// Also respect the onboarding filtering
const is_onboarding = document.body.dataset.mode === "onboarding";
this.cards = banners
.filter((card) => card.type === "hero" || card.type === "feature")
.filter((card) => !is_onboarding || card.show_in_onboarding === true)
.filter((card) => !dismissed.has(card.id))
.sort((left, right) => (right.priority || 0) - (left.priority || 0));
this.hasDismissedCards = dismissed.size > 0;
} catch (error) {
console.error("Failed to fetch discovery cards:", error);
} finally {
this._isLoading = false;
}
},
dismissCard(cardId) {
const dismissed = this._getDismissedIds();
dismissed.add(cardId);
this._persistDismissedIds(dismissed);
// Optimistically update UI
this.cards = this.cards.filter(c => c.id !== cardId);
this.hasDismissedCards = true;
},
undismissCards() {
localStorage.removeItem(STORAGE_KEY);
this.refreshCards();
},
async executeCta(action) {
if (!action) return;
try {
if (action === "open-plugin-hub") {
await pluginListStore.open("pluginHub");
return;
}
if (action.startsWith("open-plugin-config:")) {
const pluginName = action.split(":")[1];
await pluginListStore.openPluginConfig(pluginName);
return;
}
if (action.startsWith("open-url:")) {
const url = action.slice("open-url:".length);
if (url) {
window.open(url, "_blank", "noopener,noreferrer");
}
return;
}
} catch (error) {
console.error("Discovery action failed:", error);
const message = error instanceof Error ? error.message : String(error);
await toastFrontendError(message, "Discovery");
}
},
// --- Helpers (Private-ish) ---
_getDismissedIds() {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
if (!Array.isArray(raw)) return new Set();
return new Set(raw);
} catch {
return new Set();
}
},
_persistDismissedIds(ids) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(ids)));
},
// --- Computed ---
get heroCards() {
return this.cards.filter((card) => card.type === "hero");
},
get featureCards() {
return this.cards.filter((card) => card.type === "feature");
},
};
const store = createStore("discoveryStore", model);
export { store };

View file

@ -16,7 +16,7 @@
}
.onboarding-welcome-text {
text-align: center;
margin: var(--spacing-md) 0;
margin: var(--spacing-md) 0 var(--spacing-lg) 0;
color: var(--text-2);
font-size: 1.1rem;
line-height: 1.5;
@ -29,7 +29,7 @@
}
.onboarding-success {
text-align: center;
padding: 40px 0;
padding: var(--spacing-md) 0;
}
.onboarding-success-icon {
font-size: 64px;
@ -186,7 +186,7 @@
<div x-show="$store.onboarding.step === 1">
<div class="onboarding-welcome-text">
<div class="onboarding-welcome-title">Welcome to Agent Zero</div>
Let's get your models configured. The <b>Main Model</b> handles chat, tool calls, skills, and browser automation.<br> We recommend a capable model like Claude Sonnet 4.6, GPT-5.4, Kimi 2.5, or similar.
Let's get your models configured. The <b>Main Model</b> handles chat, tool calls, skills, and browser automation. We recommend a capable model like Claude Sonnet 4.6, GPT-5.4, Kimi 2.5, or similar.
</div>
<div class="model-section">
@ -270,7 +270,7 @@
<div x-show="$store.onboarding.step === 2">
<div class="onboarding-welcome-text">
<div class="onboarding-welcome-title">Almost there!</div>
The <b>Utility Model</b> handles background tasks like summarization and memory updates.<br> A fast, cheap model like GPT-5.4-mini, Gemini 3.1 Flash Lite, or similar works best here.
The <b>Utility Model</b> handles background tasks like summarization and memory updates. A fast, cheap model like GPT-5.4-mini, Gemini 3.1 Flash Lite, or similar works best here.
</div>
<div class="model-section">
@ -353,10 +353,11 @@
<!-- Step 3: Success -->
<div x-show="$store.onboarding.step === 3" class="onboarding-success">
<div class="material-symbols-outlined onboarding-success-icon">check_circle</div>
<div class="onboarding-welcome-title">Ready to chat!</div>
<div class="onboarding-welcome-text onboarding-success-text">
Your models are configured. You can change these anytime in Settings.
Your models are configured. You can change these anytime in Settings.<br>
While you're here, why not check out some integrations to extend what Agent Zero can do?
</div>
<x-extension id="onboarding-success-end"></x-extension>
</div>
<div class="onboarding-advanced-link" x-show="$store.onboarding.step < 3">

View file

@ -12,6 +12,8 @@ import {
defaultPriority,
} from "/components/notifications/notification-store.js";
const MODAL_PATH = "components/plugins/list/plugin-list.html";
// define the model object holding data and functions
const model = {
loading: false,
@ -22,11 +24,22 @@ const model = {
readmeLoading: false,
readmeError: "",
async open(tab = "custom") {
await this.setTab(tab);
window.openModal?.(MODAL_PATH);
},
async init() {
this.loading = false;
await this.setTab('custom');
if (this.plugins.length === 0) {
await this.setTab('builtin');
// If a tab is already selected (e.g. via open()), use it.
// Otherwise default to custom -> builtin fallback.
if (this.activeTab && this.activeTab !== "custom") {
await this.setTab(this.activeTab);
} else {
await this.setTab("custom");
if (this.plugins.length === 0) {
await this.setTab("builtin");
}
}
},
@ -81,13 +94,21 @@ const model = {
pluginExecuteStore.open(plugin);
},
async openPluginConfig(plugin) {
if (!plugin?.name || !plugin?.has_config_screen) return;
async openPluginConfig(pluginOrName) {
const pluginName =
typeof pluginOrName === "string" ? pluginOrName : pluginOrName?.name;
if (!pluginName) return;
// If it's an object, we can check has_config_screen.
// If it's a name, we just try to open it and let pluginSettingsStore handle errors.
if (typeof pluginOrName === "object" && !pluginOrName.has_config_screen)
return;
try {
if (!pluginSettingsStore?.openConfig) {
throw new Error("Plugin settings store is unavailable.");
}
await pluginSettingsStore.openConfig(plugin.name);
await pluginSettingsStore.openConfig(pluginName);
} catch (e) {
showErrorNotification(e, "Failed to open plugin config");
}

View file

@ -141,9 +141,9 @@ const model = {
},
get sortedBanners() {
return [...this.banners].sort(
(a, b) => (b.priority || 0) - (a.priority || 0),
);
return [...this.banners]
.filter((b) => b.type !== "hero" && b.type !== "feature")
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
},
/**

View file

@ -256,6 +256,8 @@ some classes like modal-header are shared between the old and the new system */
background: #2196f3;
color: white;
width: fit-content;
align-items: center;
display: inline-flex;
}
.btn:disabled {
cursor: not-allowed;