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