Info and Warning Banners for Welcome Screen
keyboardstaff committed
Dec 27, 2025 at 00:24 UTC
52430f6421c79bc5cc8fefd3b030d2f6e56d3d4e
5 files changed
+497
-1
python/api/banners.py
new
+48
@@ -0,0 +1,48 @@
1
+from python.helpers.api import ApiHandler, Request, Response
2
+from python.helpers.extension import Extension
3
+from python.helpers import files, extract_tools
4
+
5
+
6
+class GetBanners(ApiHandler):
7
+ """
8
+ API endpoint for Welcome Screen banners.
9
+ Add checks as extension scripts in python/extensions/banners/ or usr/extensions/banners/
10
+ """
11
+
12
+ async def process(self, input: dict, request: Request) -> dict | Response:
13
+ frontend_banners = input.get("banners", [])
14
+ frontend_context = input.get("context", {})
15
+ backend_banners = await self._run_banner_extensions(frontend_context, frontend_banners)
16
+ return {"banners": backend_banners}
17
+
18
+ async def _run_banner_extensions(self, context: dict, frontend_banners: list) -> list[dict]:
19
+ """Run all banner checks via extension point system."""
20
+ banners = []
21
+ for cls in self._get_banner_extensions():
22
+ try:
23
+ result = await cls(agent=None).execute(context=context, frontend_banners=frontend_banners)
24
+ if result:
25
+ banners.extend(result if isinstance(result, list) else [result])
26
+ except Exception as e:
27
+ print(f"Banner check failed ({cls.__name__}): {e}")
28
+ return banners
29
+
30
+ def _get_banner_extensions(self) -> list[type[Extension]]:
31
+ """Load banner extension classes from extensions folders."""
32
+ all_exts = []
33
+ for path in ["python/extensions/banners", "usr/extensions/banners"]:
34
+ abs_path = files.get_abs_path(path)
35
+ if files.exists(abs_path):
36
+ all_exts.extend(extract_tools.load_classes_from_folder(abs_path, "*", Extension))
37
+
38
+ # Deduplicate by filename (usr overrides default), sort by name
39
+ unique = {}
40
+ for cls in all_exts:
41
+ file = cls.__module__.split(".")[-1]
42
+ if file not in unique:
43
+ unique[file] = cls
44
+ return sorted(unique.values(), key=lambda c: c.__module__.split(".")[-1])
45
+
46
+ @classmethod
47
+ def get_methods(cls) -> list[str]:
48
+ return ["POST"]
python/extensions/banners/_10_unsecured_connection.py
new
+66
@@ -0,0 +1,66 @@
1
+from python.helpers.extension import Extension
2
+from python.helpers import dotenv
3
+import re
4
+
5
+
6
+class UnsecuredConnectionCheck(Extension):
7
+ """Check: non-local without credentials, or credentials over non-HTTPS."""
8
+
9
+ async def execute(self, context: dict = {}, **kwargs) -> dict | list | None:
10
+ banners = []
11
+ hostname = context.get("hostname", "")
12
+ protocol = context.get("protocol", "")
13
+
14
+ auth_login = dotenv.get_dotenv_value(dotenv.KEY_AUTH_LOGIN, "")
15
+ auth_password = dotenv.get_dotenv_value(dotenv.KEY_AUTH_PASSWORD, "")
16
+ has_credentials = bool(auth_login and auth_login.strip() and auth_password and auth_password.strip())
17
+
18
+ is_local = self._is_localhost(hostname)
19
+ is_https = protocol == "https:"
20
+
21
+ if not is_local and not has_credentials:
22
+ banners.append({
23
+ "id": "unsecured-connection",
24
+ "type": "warning",
25
+ "priority": 80,
26
+ "title": "Unsecured Connection",
27
+ "html": """You are accessing Agent Zero from a non-local address without authentication.
28
+ <a href="#" onclick="document.getElementById('settings').click(); return false;">
29
+ Configure credentials</a> in Settings → External Services → Authentication.""",
30
+ "dismissible": True,
31
+ "source": "backend"
32
+ })
33
+
34
+ if has_credentials and not is_local and not is_https:
35
+ banners.append({
36
+ "id": "credentials-unencrypted",
37
+ "type": "warning",
38
+ "priority": 90,
39
+ "title": "Credentials May Be Sent Unencrypted",
40
+ "html": """Your connection is not using HTTPS. Login credentials may be transmitted in plain text.
41
+ Consider using HTTPS or a secure tunnel.""",
42
+ "dismissible": True,
43
+ "source": "backend"
44
+ })
45
+
46
+ return banners if banners else None
47
+
48
+ def _is_localhost(self, hostname: str) -> bool:
49
+ local_patterns = ["localhost", "127.0.0.1", "::1", "0.0.0.0"]
50
+
51
+ if hostname in local_patterns:
52
+ return True
53
+
54
+ # RFC1918 private ranges
55
+ if re.match(r"^192\.168\.\d{1,3}\.\d{1,3}$", hostname):
56
+ return True
57
+ if re.match(r"^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$", hostname):
58
+ return True
59
+ if re.match(r"^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$", hostname):
60
+ return True
61
+
62
+ # .local domains
63
+ if hostname.endswith(".local"):
64
+ return True
65
+
66
+ return False
python/extensions/banners/_20_missing_api_key.py
new
+64
@@ -0,0 +1,64 @@
1
+from python.helpers.extension import Extension
2
+from python.helpers import settings as settings_helper
3
+import models
4
+
5
+
6
+class MissingApiKeyCheck(Extension):
7
+ """Check if API keys are configured for selected model providers."""
8
+
9
+ LOCAL_PROVIDERS = ["ollama", "lm_studio"]
10
+ HUGGINGFACE_LOCAL_FOR_EMBEDDING = ["huggingface"]
11
+ MODEL_TYPE_NAMES = {
12
+ "chat": "Chat Model",
13
+ "utility": "Utility Model",
14
+ "browser": "Web Browser Model",
15
+ "embedding": "Embedding Model",
16
+ }
17
+
18
+ async def execute(self, context: dict = {}, **kwargs) -> dict | None:
19
+ current_settings = settings_helper.get_settings()
20
+ model_providers = {
21
+ "chat": current_settings.get("chat_model_provider", ""),
22
+ "utility": current_settings.get("util_model_provider", ""),
23
+ "browser": current_settings.get("browser_model_provider", ""),
24
+ "embedding": current_settings.get("embed_model_provider", ""),
25
+ }
26
+
27
+ missing_providers = []
28
+
29
+ for model_type, provider in model_providers.items():
30
+ if not provider:
31
+ continue
32
+
33
+ provider_lower = provider.lower()
34
+ if provider_lower in self.LOCAL_PROVIDERS:
35
+ continue
36
+ if model_type == "embedding" and provider_lower in self.HUGGINGFACE_LOCAL_FOR_EMBEDDING:
37
+ continue
38
+
39
+ api_key = models.get_api_key(provider_lower)
40
+ if not (api_key and api_key.strip() and api_key != "None"):
41
+ missing_providers.append({
42
+ "model_type": self.MODEL_TYPE_NAMES.get(model_type, model_type),
43
+ "provider": provider,
44
+ })
45
+
46
+ if not missing_providers:
47
+ return None
48
+
49
+ model_list = ", ".join(
50
+ f"{p['model_type']} ({p['provider']})" for p in missing_providers
51
+ )
52
+
53
+ return {
54
+ "id": "missing-api-key",
55
+ "type": "error",
56
+ "priority": 100,
57
+ "title": "Missing API Key",
58
+ "html": f"""No API key configured for: {model_list}.
59
+ Agent Zero will not be able to function properly.
60
+ <a href="#" onclick="document.getElementById('settings').click(); return false;">
61
+ Add your API key</a> in Settings → External Services → API Keys.""",
62
+ "dismissible": False,
63
+ "source": "backend"
64
+ }
webui/components/welcome/welcome-screen.html
+183
-1
@@ -67,6 +67,32 @@
67
</p>
68
</div>
69
</div>
70
+
71
+ <!-- Banner Section -->
72
+ <div class="welcome-banners" x-show="banners && banners.length > 0">
73
+ <template x-for="banner in sortedBanners" :key="banner.id">
74
+ <div class="welcome-banner" :class="getBannerClass(banner.type)">
75
+ <!-- Banner Icon -->
76
+ <div class="welcome-banner-icon">
77
+ <span class="material-symbols-outlined" x-text="getBannerIcon(banner.type)"></span>
78
+ </div>
79
+
80
+ <!-- Banner Content -->
81
+ <div class="welcome-banner-content">
82
+ <div class="welcome-banner-title" x-text="banner.title"></div>
83
+ <div class="welcome-banner-html" x-html="banner.html"></div>
84
+ </div>
85
+
86
+ <!-- Dismiss Button -->
87
+ <button class="welcome-banner-dismiss"
88
+ x-show="banner.dismissible !== false"
89
+ @click.stop="dismissBanner(banner.id)"
90
+ title="Dismiss">
91
+ <span class="material-symbols-outlined">close</span>
92
+ </button>
93
+ </div>
94
+ </template>
95
+ </div>
96
</div>
97
</template>
98
</div>
@@ -168,6 +194,134 @@
194
line-height: 1.3;
195
}
196
197
+ /* Banner Styles */
198
+ .welcome-banners {
199
+ display: flex;
200
+ flex-direction: column;
201
+ gap: 0.75rem;
202
+ max-width: 520px;
203
+ width: 100%;
204
+ margin-top: 1.5rem;
205
+ }
206
+
207
+ .welcome-banner {
208
+ display: flex;
209
+ align-items: flex-start;
210
+ gap: 12px;
211
+ padding: 14px 16px;
212
+ background: var(--color-panel);
213
+ border: 1px solid var(--color-border);
214
+ border-radius: 8px;
215
+ text-align: left;
216
+ position: relative;
217
+ transition: all 0.2s ease;
218
+ }
219
+
220
+ .welcome-banner:hover {
221
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
222
+ }
223
+
224
+ /* Banner Type Styles */
225
+ .welcome-banner.banner-info {
226
+ border-left: 4px solid #2196F3;
227
+ }
228
+
229
+ .welcome-banner.banner-warning {
230
+ border-left: 4px solid #FF9800;
231
+ }
232
+
233
+ .welcome-banner.banner-error {
234
+ border-left: 4px solid #F44336;
235
+ }
236
+
237
+ /* Banner Icon */
238
+ .welcome-banner-icon {
239
+ flex-shrink: 0;
240
+ display: flex;
241
+ align-items: center;
242
+ justify-content: center;
243
+ width: 24px;
244
+ height: 24px;
245
+ margin-top: 2px;
246
+ }
247
+
248
+ .welcome-banner-icon .material-symbols-outlined {
249
+ font-size: 1.25rem;
250
+ }
251
+
252
+ .banner-info .welcome-banner-icon {
253
+ color: #2196F3;
254
+ }
255
+
256
+ .banner-warning .welcome-banner-icon {
257
+ color: #FF9800;
258
+ }
259
+
260
+ .banner-error .welcome-banner-icon {
261
+ color: #F44336;
262
+ }
263
+
264
+ /* Banner Content */
265
+ .welcome-banner-content {
266
+ flex: 1;
267
+ min-width: 0;
268
+ }
269
+
270
+ .welcome-banner-title {
271
+ font-weight: 600;
272
+ font-size: 0.9rem;
273
+ color: var(--color-text);
274
+ margin-bottom: 4px;
275
+ line-height: 1.3;
276
+ }
277
+
278
+ .welcome-banner-html {
279
+ font-size: 0.85rem;
280
+ color: var(--color-secondary);
281
+ line-height: 1.5;
282
+ }
283
+
284
+ .welcome-banner-html a {
285
+ color: var(--color-primary);
286
+ text-decoration: underline;
287
+ cursor: pointer;
288
+ }
289
+
290
+ .welcome-banner-html a:hover {
291
+ color: var(--color-highlight-dark);
292
+ }
293
+
294
+ .welcome-banner-html strong {
295
+ color: var(--color-text);
296
+ font-weight: 600;
297
+ }
298
+
299
+ /* Banner Dismiss Button */
300
+ .welcome-banner-dismiss {
301
+ flex-shrink: 0;
302
+ background: transparent;
303
+ border: none;
304
+ border-radius: 4px;
305
+ color: var(--color-secondary);
306
+ cursor: pointer;
307
+ padding: 4px;
308
+ display: flex;
309
+ align-items: center;
310
+ justify-content: center;
311
+ transition: all 0.2s ease;
312
+ opacity: 0.6;
313
+ }
314
+
315
+ .welcome-banner-dismiss:hover {
316
+ background: var(--color-border);
317
+ color: var(--color-text);
318
+ opacity: 1;
319
+ }
320
+
321
+ .welcome-banner-dismiss .material-symbols-outlined {
322
+ font-size: 1.1rem;
323
+ }
324
+
325
/* Light mode adjustments */
326
.light-mode .welcome-title {
327
color: #2d2d2d;
@@ -189,6 +343,18 @@
343
color: #666;
344
}
345
346
+ .light-mode .welcome-banner {
347
+ background: var(--color-panel);
348
+ }
349
+
350
+ .light-mode .welcome-banner-title {
351
+ color: var(--color-text);
352
+ }
353
+
354
+ .light-mode .welcome-banner-html {
355
+ color: var(--color-primary);
356
+ }
357
+
358
/* Responsive design */
359
@media (max-width: 768px) {
360
.welcome-container {
@@ -234,8 +400,24 @@
400
.welcome-action-title {
401
font-size: 1.1rem;
402
}
403
+
404
+ .welcome-banners {
405
+ max-width: 100%;
406
+ }
407
+
408
+ .welcome-banner {
409
+ padding: 12px 14px;
410
+ }
411
+
412
+ .welcome-banner-title {
413
+ font-size: 0.85rem;
414
+ }
415
+
416
+ .welcome-banner-html {
417
+ font-size: 0.8rem;
418
+ }
419
}
420
</style>
421
</body>
422
241
-</html>
\ No newline at end of file
423
+</html>
webui/components/welcome/welcome-store.js
+136
@@ -3,15 +3,23 @@ import { getContext } from "/index.js";
3
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4
import { store as memoryStore } from "/components/settings/memory/memory-dashboard-store.js";
5
import { store as projectsStore } from "/components/projects/projects-store.js";
6
+import * as API from "/js/api.js";
7
8
const model = {
9
// State
10
isVisible: true,
11
+ banners: [],
12
+ bannersLoading: false,
13
+ lastBannerRefresh: 0,
14
15
init() {
16
// Initialize visibility based on current context
17
this.updateVisibility();
18
19
+ if (this.isVisible) {
20
+ this.refreshBanners();
21
+ }
22
+
23
// Watch for context changes with faster polling for immediate response
24
setInterval(() => {
25
this.updateVisibility();
@@ -21,7 +29,12 @@ const model = {
29
// Update visibility based on current context
30
updateVisibility() {
31
const hasContext = !!getContext();
32
+ const wasVisible = this.isVisible;
33
this.isVisible = !hasContext;
34
+
35
+ if (this.isVisible && !wasVisible) {
36
+ this.refreshBanners();
37
+ }
38
},
39
40
// Hide welcome screen
@@ -32,6 +45,129 @@ const model = {
45
// Show welcome screen
46
show() {
47
this.isVisible = true;
48
+ this.refreshBanners();
49
+ },
50
+
51
+ // Build frontend context to send to backend
52
+ buildFrontendContext() {
53
+ return {
54
+ url: window.location.href,
55
+ protocol: window.location.protocol,
56
+ hostname: window.location.hostname,
57
+ port: window.location.port,
58
+ browser: navigator.userAgent,
59
+ timestamp: new Date().toISOString(),
60
+ };
61
+ },
62
+
63
+ // Frontend banner checks (most checks are on backend; add browser-only checks here)
64
+ runFrontendBannerChecks() {
65
+ return [];
66
+ },
67
+
68
+ // Call backend API for additional banners
69
+ async runBackendBannerChecks(frontendBanners, frontendContext) {
70
+ try {
71
+ const response = await API.callJsonApi("/banners", {
72
+ banners: frontendBanners,
73
+ context: frontendContext,
74
+ });
75
+ return response?.banners || [];
76
+ } catch (error) {
77
+ console.error("Failed to fetch backend banners:", error);
78
+ return [];
79
+ }
80
+ },
81
+
82
+ // Get list of dismissed banner IDs from storage
83
+ getDismissedBannerIds() {
84
+ const permanent = JSON.parse(localStorage.getItem('dismissed_banners') || '[]');
85
+ const temporary = JSON.parse(sessionStorage.getItem('dismissed_banners') || '[]');
86
+ return new Set([...permanent, ...temporary]);
87
+ },
88
+
89
+ // Merge and filter banners: deduplicate by ID, skip dismissed, sort by priority
90
+ mergeBanners(frontendBanners, backendBanners) {
91
+ const dismissed = this.getDismissedBannerIds();
92
+ const bannerMap = new Map();
93
+
94
+ for (const banner of frontendBanners) {
95
+ if (banner.id && !dismissed.has(banner.id)) {
96
+ bannerMap.set(banner.id, banner);
97
+ }
98
+ }
99
+ for (const banner of backendBanners) {
100
+ if (banner.id && !dismissed.has(banner.id)) {
101
+ bannerMap.set(banner.id, banner);
102
+ }
103
+ }
104
+
105
+ return Array.from(bannerMap.values()).sort((a, b) => (b.priority || 0) - (a.priority || 0));
106
+ },
107
+
108
+ // Refresh banners: frontend checks → backend checks → merge
109
+ async refreshBanners() {
110
+ const now = Date.now();
111
+ if (now - this.lastBannerRefresh < 1000) return;
112
+ this.lastBannerRefresh = now;
113
+ this.bannersLoading = true;
114
+
115
+ try {
116
+ const frontendContext = this.buildFrontendContext();
117
+ const frontendBanners = this.runFrontendBannerChecks();
118
+ const backendBanners = await this.runBackendBannerChecks(frontendBanners, frontendContext);
119
+ this.banners = this.mergeBanners(frontendBanners, backendBanners);
120
+ } catch (error) {
121
+ console.error("Failed to refresh banners:", error);
122
+ this.banners = this.runFrontendBannerChecks();
123
+ } finally {
124
+ this.bannersLoading = false;
125
+ }
126
+ },
127
+
128
+ get sortedBanners() {
129
+ return [...this.banners].sort((a, b) => (b.priority || 0) - (a.priority || 0));
130
+ },
131
+
132
+ /**
133
+ * Dismiss a banner by ID.
134
+ *
135
+ * Usage:
136
+ * dismissBanner('banner-id') - Temporary dismiss (sessionStorage, cleared on browser close)
137
+ * dismissBanner('banner-id', true) - Permanent dismiss (localStorage, persists across sessions)
138
+ *
139
+ * Dismissed banners are filtered out in mergeBanners() and won't appear until storage is cleared.
140
+ *
141
+ * @param {string} bannerId - The unique ID of the banner to dismiss
142
+ * @param {boolean} permanent - If true, store in localStorage; if false, store in sessionStorage
143
+ */
144
+ dismissBanner(bannerId, permanent = false) {
145
+ this.banners = this.banners.filter(b => b.id !== bannerId);
146
+
147
+ const storage = permanent ? localStorage : sessionStorage;
148
+ const dismissed = JSON.parse(storage.getItem('dismissed_banners') || '[]');
149
+ if (!dismissed.includes(bannerId)) {
150
+ dismissed.push(bannerId);
151
+ storage.setItem('dismissed_banners', JSON.stringify(dismissed));
152
+ }
153
+ },
154
+
155
+ getBannerClass(type) {
156
+ const classes = {
157
+ info: 'banner-info',
158
+ warning: 'banner-warning',
159
+ error: 'banner-error',
160
+ };
161
+ return classes[type] || 'banner-info';
162
+ },
163
+
164
+ getBannerIcon(type) {
165
+ const icons = {
166
+ info: 'info',
167
+ warning: 'warning',
168
+ error: 'error',
169
+ };
170
+ return icons[type] || 'info';
171
},
172
173
// Execute an action by ID