finalize the notification system
Rafael Uzarowski committed
Jul 12, 2025 at 16:27 UTC
e32a04fb77d3706dc81d94b0c133299737ed3318
18 files changed
+453
-334
docs/notifications.md
new
+130
@@ -0,0 +1,130 @@
1
+# Agent Zero Notifications
2
+
3
+Quick guide for using the notification system in Agent Zero.
4
+
5
+## Backend Usage
6
+
7
+Use `AgentNotification` helper methods anywhere in your Python code:
8
+
9
+```python
10
+from python.helpers.notification import AgentNotification
11
+
12
+# Basic notifications
13
+AgentNotification.info("Operation completed")
14
+AgentNotification.success("File saved successfully", "File Manager")
15
+AgentNotification.warning("High CPU usage detected", "System Monitor")
16
+AgentNotification.error("Connection failed", "Network Error")
17
+AgentNotification.progress("Processing files...", "Task Progress")
18
+
19
+# With details and custom display time
20
+AgentNotification.info(
21
+ message="System backup completed",
22
+ title="Backup Manager",
23
+ detail="<p>Backup size: <strong>2.4 GB</strong></p>",
24
+ display_time=8 # seconds
25
+)
26
+
27
+# Grouped notifications (replaces previous in same group)
28
+AgentNotification.progress("Download: 25%", "File Download", group="download-status")
29
+AgentNotification.progress("Download: 75%", "File Download", group="download-status") # Replaces previous
30
+AgentNotification.progress("Download: Complete!", "File Download", group="download-status") # Replaces previous
31
+```
32
+
33
+## Frontend Usage
34
+
35
+Use the notification store in Alpine.js components:
36
+
37
+```javascript
38
+// Basic notifications
39
+$store.notificationStore.info("User logged in")
40
+$store.notificationStore.success("Settings saved", "Configuration")
41
+$store.notificationStore.warning("Session expiring soon")
42
+$store.notificationStore.error("Failed to load data")
43
+
44
+// With grouping
45
+$store.notificationStore.info("Connecting...", "Status", "", 3, "connection")
46
+$store.notificationStore.success("Connected!", "Status", "", 3, "connection") // Replaces previous
47
+
48
+// Frontend notifications with backend persistence (new feature!)
49
+$store.notificationStore.frontendError("Database timeout", "Connection Error")
50
+$store.notificationStore.frontendWarning("High memory usage", "Performance")
51
+$store.notificationStore.frontendInfo("Cache cleared", "System")
52
+```
53
+
54
+## Frontend Notifications with Backend Sync
55
+
56
+**New Feature**: Frontend notifications now automatically sync to the backend when connected, providing persistent history and cross-session availability.
57
+
58
+### How it Works:
59
+- **Backend Connected**: Notifications are sent to backend and appear via polling (persistent)
60
+- **Backend Disconnected**: Notifications show as frontend-only toasts (temporary)
61
+- **Automatic Fallback**: Seamless degradation when backend is unavailable
62
+
63
+### Global Functions:
64
+```javascript
65
+// These functions automatically try backend first, then fallback to frontend-only
66
+toastFrontendError("Server unreachable", "Connection Error")
67
+toastFrontendWarning("Slow connection detected")
68
+toastFrontendInfo("Reconnected successfully")
69
+```
70
+
71
+## HTML Usage
72
+
73
+```html
74
+<button @click="$store.notificationStore.success('Task completed!')">
75
+ Complete Task
76
+</button>
77
+
78
+<button @click="$store.notificationStore.warning('Progress: 50%', 'Upload', '', 5, 'upload-progress')">
79
+ Update Progress
80
+</button>
81
+
82
+<!-- Frontend notifications with backend sync -->
83
+<button @click="$store.notificationStore.frontendError('Connection failed', 'Network')">
84
+ Report Connection Error
85
+</button>
86
+```
87
+
88
+## Notification Groups
89
+
90
+Groups ensure only the latest notification from each group is shown in the toast stack:
91
+
92
+```python
93
+# Progress updates - each new notification replaces the previous one
94
+AgentNotification.info("Starting backup...", group="backup-status")
95
+AgentNotification.progress("Backup: 30%", group="backup-status") # Replaces previous
96
+AgentNotification.progress("Backup: 80%", group="backup-status") # Replaces previous
97
+AgentNotification.success("Backup complete!", group="backup-status") # Replaces previous
98
+
99
+# Connection status - only show current state
100
+AgentNotification.warning("Disconnected", group="network")
101
+AgentNotification.info("Reconnecting...", group="network") # Replaces previous
102
+AgentNotification.success("Connected", group="network") # Replaces previous
103
+```
104
+
105
+## Parameters
106
+
107
+All notification methods support these parameters:
108
+
109
+- `message` (required): Main notification text
110
+- `title` (optional): Notification title
111
+- `detail` (optional): HTML content for expandable details
112
+- `display_time` (optional): Toast display duration in seconds (default: 3)
113
+- `group` (optional): Group identifier for replacement behavior
114
+
115
+## Types
116
+
117
+- **info** (ℹ️): General information
118
+- **success** (✅): Successful operations
119
+- **warning** (⚠️): Important alerts
120
+- **error** (❌): Error conditions
121
+- **progress** (⏳): Ongoing operations
122
+
123
+## Behavior
124
+
125
+- **Toast Display**: Notifications appear as toasts in the bottom-right corner
126
+- **Persistent History**: All notifications (including synced frontend ones) are stored in notification history
127
+- **Modal Access**: Full history accessible via the bell icon
128
+- **Auto-dismiss**: Toasts automatically disappear after `display_time`
129
+- **Group Replacement**: Notifications with the same group replace previous ones immediately
130
+- **Backend Sync**: Frontend notifications automatically sync to backend when connected
python/api/notification_create.py
+7
-6
@@ -15,6 +15,7 @@ class NotificationCreate(ApiHandler):
15
title = input.get("title", "")
16
detail = input.get("detail", "")
17
display_time = input.get("display_time", 3) # Default to 3 seconds
18
+ group = input.get("group", "") # Group parameter for notification grouping
19
20
# Validate required fields
21
if not message:
@@ -38,17 +39,17 @@ class NotificationCreate(ApiHandler):
39
# Create notification using the appropriate helper method
40
try:
41
if notification_type == NotificationType.INFO:
41
- notification = AgentNotification.info(message, title, detail, display_time)
42
+ notification = AgentNotification.info(message, title, detail, display_time, group)
43
elif notification_type == NotificationType.SUCCESS:
43
- notification = AgentNotification.success(message, title, detail, display_time)
44
+ notification = AgentNotification.success(message, title, detail, display_time, group)
45
elif notification_type == NotificationType.WARNING:
45
- notification = AgentNotification.warning(message, title, detail, display_time)
46
+ notification = AgentNotification.warning(message, title, detail, display_time, group)
47
elif notification_type == NotificationType.ERROR:
47
- notification = AgentNotification.error(message, title, detail, display_time)
48
+ notification = AgentNotification.error(message, title, detail, display_time, group)
49
elif notification_type == NotificationType.PROGRESS:
49
- notification = AgentNotification.progress(message, title, detail, display_time)
50
+ notification = AgentNotification.progress(message, title, detail, display_time, group)
51
else:
51
- notification = AgentNotification.info(message, title, detail, display_time)
52
+ notification = AgentNotification.info(message, title, detail, display_time, group)
53
54
return {
55
"success": True,
python/helpers/notification.py
+14
-10
@@ -24,6 +24,7 @@ class NotificationItem:
24
display_time: int = 3 # Display duration in seconds, default 3 seconds
25
read: bool = False
26
id: str = ""
27
+ group: str = "" # Group identifier for grouping related notifications
28
29
def __post_init__(self):
30
if not self.id:
@@ -47,6 +48,7 @@ class NotificationItem:
48
"timestamp": self.timestamp.isoformat(),
49
"display_time": self.display_time,
50
"read": self.read,
51
+ "group": self.group,
52
}
53
54
@@ -64,6 +66,7 @@ class NotificationManager:
66
title: str = "",
67
detail: str = "",
68
display_time: int = 3,
69
+ group: str = "",
70
) -> NotificationItem:
71
# Create notification item
72
item = NotificationItem(
@@ -75,6 +78,7 @@ class NotificationManager:
78
detail=detail,
79
timestamp=datetime.now(timezone.utc),
80
display_time=display_time,
81
+ group=group,
82
)
83
84
# Add to notifications
@@ -139,36 +143,36 @@ class NotificationManager:
143
144
class AgentNotification:
145
@staticmethod
142
- def info(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
146
+ def info(message: str, title: str = "", detail: str = "", display_time: int = 3, group: str = "") -> NotificationItem:
147
from agent import AgentContext
148
return AgentContext.get_notification_manager().add_notification(
145
- NotificationType.INFO, message, title, detail, display_time
149
+ NotificationType.INFO, message, title, detail, display_time, group
150
)
151
152
@staticmethod
149
- def success(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
153
+ def success(message: str, title: str = "", detail: str = "", display_time: int = 3, group: str = "") -> NotificationItem:
154
from agent import AgentContext
155
return AgentContext.get_notification_manager().add_notification(
152
- NotificationType.SUCCESS, message, title, detail, display_time
156
+ NotificationType.SUCCESS, message, title, detail, display_time, group
157
)
158
159
@staticmethod
156
- def warning(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
160
+ def warning(message: str, title: str = "", detail: str = "", display_time: int = 3, group: str = "") -> NotificationItem:
161
from agent import AgentContext
162
return AgentContext.get_notification_manager().add_notification(
159
- NotificationType.WARNING, message, title, detail, display_time
163
+ NotificationType.WARNING, message, title, detail, display_time, group
164
)
165
166
@staticmethod
163
- def error(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
167
+ def error(message: str, title: str = "", detail: str = "", display_time: int = 3, group: str = "") -> NotificationItem:
168
from agent import AgentContext
169
return AgentContext.get_notification_manager().add_notification(
166
- NotificationType.ERROR, message, title, detail, display_time
170
+ NotificationType.ERROR, message, title, detail, display_time, group
171
)
172
173
@staticmethod
170
- def progress(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
174
+ def progress(message: str, title: str = "", detail: str = "", display_time: int = 3, group: str = "") -> NotificationItem:
175
from agent import AgentContext
176
return AgentContext.get_notification_manager().add_notification(
173
- NotificationType.PROGRESS, message, title, detail, display_time
177
+ NotificationType.PROGRESS, message, title, detail, display_time, group
178
)
webui/components/notifications/notification-icons.html
+1
-16
@@ -2,12 +2,11 @@
2
<head>
3
<script type="module">
4
import { store } from "/js/notificationStore.js";
5
- console.log('Notification component script loaded');
5
</script>
6
</head>
7
<body>
8
<div x-data>
10
- <!-- Notification Toggle Button (always visible and clickable) -->
9
+ <!-- Notification Toggle Button -->
10
<div class="notification-toggle"
11
:class="{
12
'has-unread': $store.notificationStore.unreadCount > 0,
@@ -22,20 +21,6 @@
21
class="notification-badge"
22
x-text="$store.notificationStore.unreadCount"></span>
23
</div>
25
-
26
- <!-- Test Notification Button (for development) -->
27
- <button class="notification-test-button"
28
- @click="$store.notificationStore.info('Test notification message', 'Test Title', '<p>This is test detail content with <strong>HTML</strong> formatting.</p>')"
29
- title="Create Test Notification">
30
- <div class="notification-icon">🧪</div>
31
- </button>
32
-
33
- <!-- Test Frontend Error Button (for development) -->
34
- <button class="notification-test-button frontend-error"
35
- @click="toastFrontendError('Backend connection failed', 'Connection Error')"
36
- title="Test Frontend Error Toast">
37
- <div class="notification-icon">⚠️</div>
38
- </button>
24
</div>
25
</body>
26
</html>
webui/components/notifications/notification-modal.html
-3
@@ -341,9 +341,6 @@
341
function notificationModalComponent() {
342
return {
343
init() {
344
- // Initialize component when modal loads
345
- console.log('Notification modal component initialized');
346
-
344
// Mark all notifications as read when modal opens
345
// This can be configured later if needed
346
setTimeout(() => {
webui/components/settings/backup/backup-store.js
+6
-5
@@ -3,6 +3,7 @@ import { createStore } from "/js/AlpineStore.js";
3
// Global function references
4
const sendJsonData = window.sendJsonData;
5
const toast = window.toast;
6
+const fetchApi = window.fetchApi;
7
8
// ⚠️ CRITICAL: The .env file contains API keys and essential configuration.
9
// This file is REQUIRED for Agent Zero to function and must be backed up.
@@ -375,9 +376,9 @@ const model = {
376
const fileList = this.previewFiles.map(f => f.path).join('\n');
377
try {
378
await navigator.clipboard.writeText(fileList);
378
- toast('File list copied to clipboard', 'success');
379
+ window.toastFrontendInfo('File list copied to clipboard', 'Clipboard');
380
} catch (error) {
380
- toast('Failed to copy to clipboard', 'error');
381
+ window.toastFrontendError('Failed to copy to clipboard', 'Clipboard Error');
382
}
383
},
384
@@ -419,7 +420,7 @@ const model = {
420
window.URL.revokeObjectURL(url);
421
422
this.addFileOperation('Backup created and downloaded successfully!');
422
- toast('Backup created and downloaded successfully', 'success');
423
+ window.toastFrontendInfo('Backup created and downloaded successfully', 'Backup Status');
424
} else {
425
// Try to parse error response
426
const errorText = await response.text();
@@ -677,7 +678,7 @@ const model = {
678
}
679
680
if (warnings.length > 0) {
680
- toast(`Compatibility warnings: ${warnings.join(', ')}`, 'warning');
681
+ window.toastFrontendWarning(`Compatibility warnings: ${warnings.join(', ')}`, 'Backup Compatibility');
682
}
683
},
684
@@ -746,7 +747,7 @@ const model = {
747
748
this.addFileOperation(`\nRestore completed: ${deletedCount} deleted, ${restoredCount} restored, ${skippedCount} skipped, ${errorCount} errors`);
749
this.restoreResult = result;
749
- toast('Restore completed successfully', 'success');
750
+ window.toastFrontendInfo('Restore completed successfully', 'Restore Status');
751
} else {
752
this.error = result.error;
753
this.addFileOperation(`Error: ${result.error}`);
webui/components/settings/mcp/client/mcp-servers-store.js
-1
@@ -113,7 +113,6 @@ const model = {
113
scrollModal("mcp-servers-status");
114
} catch (error) {
115
console.error("Failed to apply MCP servers:", error);
116
- alert("Failed to apply MCP servers: " + error.message);
116
}
117
this.loading = false;
118
},
webui/css/notification.css
-53
@@ -83,42 +83,7 @@
83
transform: none;
84
}
85
86
-/* Test Button (for development) - positioned next to bell icon */
87
-.notification-test-button {
88
- display: flex;
89
- align-items: center;
90
- justify-content: center;
91
- width: 36px;
92
- height: 36px;
93
- background: rgba(33, 150, 243, 0.1);
94
- border: 1px solid rgba(33, 150, 243, 0.3);
95
- border-radius: 6px;
96
- color: var(--color-text);
97
- cursor: pointer;
98
- transition: all 0.2s ease;
99
- font-size: 0.85rem;
100
- margin-left: 0.5rem;
101
-}
86
103
-.notification-test-button:hover {
104
- background: rgba(33, 150, 243, 0.2);
105
- border-color: rgba(33, 150, 243, 0.5);
106
-}
107
-
108
-.notification-test-button .notification-icon {
109
- font-size: 1.2rem;
110
-}
111
-
112
-/* Frontend Error Test Button - distinct orange/red styling */
113
-.notification-test-button.frontend-error {
114
- background: rgba(255, 152, 0, 0.15);
115
- border-color: rgba(255, 152, 0, 0.4);
116
-}
117
-
118
-.notification-test-button.frontend-error:hover {
119
- background: rgba(255, 152, 0, 0.25);
120
- border-color: rgba(255, 152, 0, 0.6);
121
-}
87
88
/* Light Mode Styles */
89
.light-mode .notification-toggle {
@@ -141,25 +106,7 @@
106
border-color: rgba(0, 0, 0, 0.1);
107
}
108
144
-.light-mode .notification-test-button {
145
- background: rgba(33, 150, 243, 0.1);
146
- border-color: rgba(33, 150, 243, 0.3);
147
-}
148
-
149
-.light-mode .notification-test-button:hover {
150
- background: rgba(33, 150, 243, 0.2);
151
- border-color: rgba(33, 150, 243, 0.5);
152
-}
153
-
154
-.light-mode .notification-test-button.frontend-error {
155
- background: rgba(255, 152, 0, 0.15);
156
- border-color: rgba(255, 152, 0, 0.4);
157
-}
109
159
-.light-mode .notification-test-button.frontend-error:hover {
160
- background: rgba(255, 152, 0, 0.25);
161
- border-color: rgba(255, 152, 0, 0.6);
162
-}
110
111
/* Animations */
112
@keyframes pulse {
webui/index.js
+52
-95
@@ -23,7 +23,7 @@ const timeDate = document.getElementById("time-date-container");
23
24
let autoScroll = true;
25
let context = "";
26
-let connectionStatus = false;
26
+let connectionStatus = undefined; // undefined = not checked yet, true = connected, false = disconnected
27
28
// Initialize the toggle button
29
setupSidebarToggle();
@@ -161,20 +161,26 @@ export async function sendMessage() {
161
}
162
}
163
} catch (e) {
164
- toastFetchError("Error sending message", e);
164
+ toastFetchError("Error sending message", e); // Will use new notification system
165
}
166
}
167
168
function toastFetchError(text, error) {
169
+ console.error(text, error);
170
+ // Use new frontend error notification system (async, but we don't need to wait)
171
+ const errorMessage = error?.message || error?.toString() || "Unknown error";
172
+
173
if (getConnectionStatus()) {
170
- toast(`${text}: ${error.message}`, "error");
174
+ // Backend is connected, just show the error
175
+ toastFrontendError(`${text}: ${errorMessage}`).catch(e =>
176
+ console.error('Failed to show error toast:', e)
177
+ );
178
} else {
172
- toast(
173
- `${text} (it seems the backend is not running): ${error.message}`,
174
- "error"
179
+ // Backend is disconnected, show connection error
180
+ toastFrontendError(`${text} (backend appears to be disconnected): ${errorMessage}`, "Connection Error").catch(e =>
181
+ console.error('Failed to show connection error toast:', e)
182
);
183
}
177
- console.error(text, error);
184
}
185
window.toastFetchError = toastFetchError;
186
@@ -373,7 +379,17 @@ async function poll() {
379
380
// Update notifications from response
381
if (globalThis.Alpine?.store('notificationStore')) {
376
- globalThis.Alpine.store('notificationStore').updateFromPoll(response);
382
+ const notificationStore = globalThis.Alpine.store('notificationStore');
383
+
384
+ // Ensure store is initialized
385
+ if (!notificationStore.lastNotificationGuid && response.notifications_guid) {
386
+ console.log('Initializing notification store on fresh load');
387
+ notificationStore.initialize();
388
+ }
389
+
390
+ notificationStore.updateFromPoll(response);
391
+ } else {
392
+ console.warn('Notification store not available during poll');
393
}
394
395
//set ui model vars from backend
@@ -813,8 +829,6 @@ window.restart = async function () {
829
const resp = await sendJsonData("/health", {});
830
// Server is back up, show success message
831
await new Promise((resolve) => setTimeout(resolve, 250));
816
- hideToast();
817
- await new Promise((resolve) => setTimeout(resolve, 400));
832
toast("Restarted", "success", 5000);
833
return;
834
} catch (e) {
@@ -825,8 +839,6 @@ window.restart = async function () {
839
}
840
841
// If we get here, restart failed or took too long
828
- hideToast();
829
- await new Promise((resolve) => setTimeout(resolve, 400));
842
toast("Restart timed out or failed", "error", 5000);
843
}
844
};
@@ -955,95 +967,32 @@ function removeClassFromElement(element, className) {
967
}
968
969
function toast(text, type = "info", timeout = 5000) {
958
- const toast = document.getElementById("toast");
959
- const isVisible = toast.classList.contains("show");
960
-
961
- // Clear any existing timeout immediately
962
- if (toast.timeoutId) {
963
- clearTimeout(toast.timeoutId);
964
- toast.timeoutId = null;
965
- }
966
-
967
- // Function to update toast content and show it
968
- const updateAndShowToast = () => {
969
- // Update the toast content and type
970
- const title = type.charAt(0).toUpperCase() + type.slice(1);
971
- toast.querySelector(".toast__title").textContent = title;
972
- toast.querySelector(".toast__message").textContent = text;
973
-
974
- // Remove old classes and add new ones
975
- toast.classList.remove("toast--success", "toast--error", "toast--info");
976
- toast.classList.add(`toast--${type}`);
977
-
978
- // Show/hide copy button based on toast type
979
- const copyButton = toast.querySelector(".toast__copy");
980
- copyButton.style.display = type === "error" ? "inline-block" : "none";
981
-
982
- // Add the close button event listener
983
- const closeButton = document.querySelector(".toast__close");
984
- closeButton.onclick = () => {
985
- hideToast();
986
- };
987
-
988
- // Add the copy button event listener
989
- copyButton.onclick = () => {
990
- navigator.clipboard.writeText(text);
991
- copyButton.textContent = "Copied!";
992
- setTimeout(() => {
993
- copyButton.textContent = "Copy";
994
- }, 2000);
995
- };
996
-
997
- // Show the toast
998
- toast.style.display = "flex";
999
- // Force a reflow to ensure the animation triggers
1000
- void toast.offsetWidth;
1001
- toast.classList.add("show");
1002
-
1003
- // Set timeout if specified
1004
- if (timeout) {
1005
- const minTimeout = Math.max(timeout, 5000);
1006
- toast.timeoutId = setTimeout(() => {
1007
- hideToast();
1008
- }, minTimeout);
970
+ // Convert timeout from milliseconds to seconds for new notification system
971
+ const display_time = Math.max(timeout / 1000, 3); // Minimum 3 seconds
972
+
973
+ // Use new frontend notification system based on type
974
+ if (window.Alpine && window.Alpine.store && window.Alpine.store('notificationStore')) {
975
+ const store = window.Alpine.store('notificationStore');
976
+ switch (type.toLowerCase()) {
977
+ case 'error':
978
+ return store.frontendError(text, "Error", display_time);
979
+ case 'success':
980
+ return store.frontendInfo(text, "Success", display_time);
981
+ case 'warning':
982
+ return store.frontendWarning(text, "Warning", display_time);
983
+ case 'info':
984
+ default:
985
+ return store.frontendInfo(text, "Info", display_time);
986
}
1010
- };
1011
-
1012
- if (isVisible) {
1013
- // If a toast is visible, hide it first then show the new one
1014
- toast.classList.remove("show");
1015
- toast.classList.add("hide");
1016
-
1017
- // Wait for hide animation to complete before showing new toast
1018
- setTimeout(() => {
1019
- toast.classList.remove("hide");
1020
- updateAndShowToast();
1021
- }, 400); // Match this with CSS transition duration
987
} else {
1023
- // If no toast is visible, show the new one immediately
1024
- updateAndShowToast();
988
+ // Fallback if Alpine/store not ready
989
+ console.log(`${type.toUpperCase()}: ${text}`);
990
+ return null;
991
}
992
}
993
window.toast = toast;
994
1029
-function hideToast() {
1030
- const toast = document.getElementById("toast");
1031
-
1032
- // Clear any existing timeout
1033
- if (toast.timeoutId) {
1034
- clearTimeout(toast.timeoutId);
1035
- toast.timeoutId = null;
1036
- }
1037
-
1038
- toast.classList.remove("show");
1039
- toast.classList.add("hide");
1040
-
1041
- // Wait for the hide animation to complete before removing from display
1042
- setTimeout(() => {
1043
- toast.style.display = "none";
1044
- toast.classList.remove("hide");
1045
- }, 400); // Match this with CSS transition duration
1046
-}
995
+// OLD: hideToast function removed - now using new notification system
996
997
function scrollChanged(isAtBottom) {
998
if (window.Alpine && autoScrollSwitch) {
@@ -1106,6 +1055,14 @@ document.addEventListener("DOMContentLoaded", function () {
1055
setupSidebarToggle();
1056
setupTabs();
1057
initializeActiveTab();
1058
+
1059
+ // Initialize notification store early to ensure it's ready for polling
1060
+ setTimeout(() => {
1061
+ if (globalThis.Alpine?.store('notificationStore')) {
1062
+ globalThis.Alpine.store('notificationStore').initialize();
1063
+ console.log('Notification store initialized on DOM ready');
1064
+ }
1065
+ }, 100); // Small delay to ensure Alpine is ready
1066
});
1067
1068
// Setup tabs functionality
webui/js/file_browser.js
+6
-6
@@ -53,7 +53,7 @@ const fileBrowserModalProxy = {
53
this.browser.entries = [];
54
}
55
} catch (error) {
56
- window.toastFetchError("Error fetching files", error);
56
+ window.toastFrontendError("Error fetching files: " + error.message, "File Browser Error");
57
this.browser.entries = [];
58
} finally {
59
this.isLoading = false;
@@ -134,7 +134,7 @@ const fileBrowserModalProxy = {
134
alert(`Error deleting file: ${await response.text()}`);
135
}
136
} catch (error) {
137
- window.toastFetchError("Error deleting file", error);
137
+ window.toastFrontendError("Error deleting file: " + error.message, "File Delete Error");
138
alert("Error deleting file");
139
}
140
},
@@ -188,7 +188,7 @@ const fileBrowserModalProxy = {
188
alert(data.message);
189
}
190
} catch (error) {
191
- window.toastFetchError("Error uploading files", error);
191
+ window.toastFrontendError("Error uploading files: " + error.message, "File Upload Error");
192
alert("Error uploading files");
193
}
194
},
@@ -215,7 +215,7 @@ const fileBrowserModalProxy = {
215
document.body.removeChild(link);
216
window.URL.revokeObjectURL(link.href);
217
} catch (error) {
218
- window.toastFetchError("Error downloading file", error);
218
+ window.toastFrontendError("Error downloading file: " + error.message, "File Download Error");
219
alert("Error downloading file");
220
}
221
},
@@ -267,7 +267,7 @@ openFileLink = async function (path) {
267
try {
268
const resp = await window.sendJsonData("/file_info", { path });
269
if (!resp.exists) {
270
- window.toast("File does not exist.", "error");
270
+ window.toastFrontendError("File does not exist.", "File Error");
271
return;
272
}
273
@@ -280,7 +280,7 @@ openFileLink = async function (path) {
280
});
281
}
282
} catch (e) {
283
- window.toastFetchError("Error opening file", e);
283
+ window.toastFrontendError("Error opening file: " + e.message, "File Open Error");
284
}
285
};
286
window.openFileLink = openFileLink;
webui/js/history.js
+2
-2
@@ -8,7 +8,7 @@ export async function openHistoryModal() {
8
const size = hist.tokens
9
await showEditorModal(data, "markdown", `History ~${size} tokens`, "Conversation history visible to the LLM. History is compressed to fit into the context window over time.");
10
} catch (e) {
11
- window.toastFetchError("Error fetching history", e)
11
+ window.toastFrontendError("Error fetching history: " + e.message, "Chat History Error");
12
return
13
}
14
}
@@ -20,7 +20,7 @@ export async function openCtxWindowModal() {
20
const size = win.tokens
21
await showEditorModal(data, "markdown", `Context window ~${size} tokens`, "Data passed to the LLM during last interaction. Contains system message, conversation history and RAG.");
22
} catch (e) {
23
- window.toastFetchError("Error fetching context", e)
23
+ window.toastFrontendError("Error fetching context: " + e.message, "Context Error");
24
return
25
}
26
}
webui/js/image_modal.js
+8
-8
@@ -4,13 +4,13 @@ let activeIntervalId = null;
4
export async function openImageModal(src, refreshInterval = 0) {
5
try {
6
let imgSrc = src;
7
-
7
+
8
// Clear any existing refresh interval
9
if (activeIntervalId !== null) {
10
clearInterval(activeIntervalId);
11
activeIntervalId = null;
12
}
13
-
13
+
14
if (refreshInterval > 0) {
15
// Add or update timestamp to bypass cache
16
const addTimestamp = (url) => {
@@ -23,7 +23,7 @@ export async function openImageModal(src, refreshInterval = 0) {
23
const isImageViewerActive = () => {
24
const container = document.querySelector('#image-viewer-container');
25
if (!container) return false;
26
-
26
+
27
// Check if element or any parent is hidden
28
let element = container;
29
while (element) {
@@ -46,7 +46,7 @@ export async function openImageModal(src, refreshInterval = 0) {
46
tempImg.onerror = reject;
47
tempImg.src = nextSrc;
48
});
49
-
49
+
50
try {
51
// Wait for preload to complete
52
const loadedSrc = await preloadPromise;
@@ -58,9 +58,9 @@ export async function openImageModal(src, refreshInterval = 0) {
58
console.error('Failed to preload image:', err);
59
}
60
};
61
-
61
+
62
imgSrc = addTimestamp(src);
63
-
63
+
64
// Set up periodic refresh with preloading
65
activeIntervalId = setInterval(() => {
66
if (!isImageViewerActive()) {
@@ -77,11 +77,11 @@ export async function openImageModal(src, refreshInterval = 0) {
77
78
const html = `<div id="image-viewer-container"><img class="image-viewer-img" src="${imgSrc}" /></div>`;
79
const fileName = src.split("/").pop();
80
-
80
+
81
// Open the modal with the generated HTML
82
await window.genericModalProxy.openModal(fileName, "", html);
83
} catch (e) {
84
- window.toastFetchError("Error fetching history", e);
84
+ window.toastFrontendError("Error fetching history: " + e.message, "Image History Error");
85
return;
86
}
87
}
webui/js/notificationStore.js
+119
-38
@@ -14,7 +14,6 @@ const model = {
14
15
// Initialize the notification store
16
initialize() {
17
- console.log("NotificationStore: Initializing with toast stack");
17
this.loading = true;
18
this.updateUnreadCount();
19
this.removeOldNotifications();
@@ -31,6 +30,8 @@ const model = {
30
updateFromPoll(pollData) {
31
if (!pollData) return;
32
33
+ const isFirstLoad = !this.lastNotificationGuid && pollData.notifications_guid;
34
+
35
// Check if GUID changed (system restart)
36
if (pollData.notifications_guid !== this.lastNotificationGuid) {
37
this.lastNotificationVersion = 0;
@@ -45,7 +46,7 @@ const model = {
46
const isNew = !this.notifications.find(n => n.id === notification.id);
47
this.addOrUpdateNotification(notification);
48
48
- // Add new notifications to toast stack
49
+ // Add new unread notifications to toast stack
50
if (isNew && !notification.read) {
51
this.addToToastStack(notification);
52
}
@@ -70,6 +71,21 @@ const model = {
71
72
// NEW: Add notification to toast stack
73
addToToastStack(notification) {
74
+ // If notification has a group, remove any existing toasts with the same group
75
+ if (notification.group && notification.group.trim() !== "") {
76
+ const existingToastIndex = this.toastStack.findIndex(t =>
77
+ t.group === notification.group
78
+ );
79
+
80
+ if (existingToastIndex >= 0) {
81
+ const existingToast = this.toastStack[existingToastIndex];
82
+ if (existingToast.autoRemoveTimer) {
83
+ clearTimeout(existingToast.autoRemoveTimer);
84
+ }
85
+ this.toastStack.splice(existingToastIndex, 1);
86
+ }
87
+ }
88
+
89
// Create toast object with auto-dismiss timer
90
const toast = {
91
...notification,
@@ -93,8 +109,6 @@ const model = {
109
toast.autoRemoveTimer = setTimeout(() => {
110
this.removeFromToastStack(toast.toastId);
111
}, notification.display_time * 1000);
96
-
97
- console.log(`Toast added: ${notification.type} - ${notification.message}`);
112
},
113
114
// NEW: Remove toast from stack
@@ -106,7 +120,6 @@ const model = {
120
clearTimeout(toast.autoRemoveTimer);
121
}
122
this.toastStack.splice(index, 1);
109
- console.log(`Toast removed: ${toastId}`);
123
}
124
},
125
@@ -118,7 +131,6 @@ const model = {
131
}
132
});
133
this.toastStack = [];
121
- console.log('Toast stack cleared');
134
},
135
136
// NEW: Clean up expired toasts (backup cleanup)
@@ -140,7 +152,6 @@ const model = {
152
153
// NEW: Handle toast click (opens modal)
154
async handleToastClick(toastId) {
143
- console.log(`Toast clicked: ${toastId}`);
155
await this.openModal();
156
// Modal opening will clear toast stack via markAllAsRead
157
},
@@ -213,7 +224,6 @@ const model = {
224
this.clearToastStack(); // Also clear toast stack
225
226
// Note: We don't sync clear with backend as notifications are stored in memory only
216
- console.log('All notifications cleared');
227
},
228
229
// Get notifications by type
@@ -309,18 +319,18 @@ const model = {
319
},
320
321
// Create notification via backend (will appear via polling)
312
- async createNotification(type, message, title = "", detail = "", display_time = 3) {
322
+ async createNotification(type, message, title = "", detail = "", display_time = 3, group = "") {
323
try {
324
const response = await window.sendJsonData('/notification_create', {
325
type: type,
326
message: message,
327
title: title,
328
detail: detail,
319
- display_time: display_time
329
+ display_time: display_time,
330
+ group: group
331
});
332
333
if (response.success) {
323
- console.log('Notification created:', response.notification_id);
334
return response.notification_id;
335
} else {
336
console.error('Failed to create notification:', response.error);
@@ -333,24 +343,24 @@ const model = {
343
},
344
345
// Convenience methods for different notification types
336
- async info(message, title = "", detail = "", display_time = 3) {
337
- return await this.createNotification('info', message, title, detail, display_time);
346
+ async info(message, title = "", detail = "", display_time = 3, group = "") {
347
+ return await this.createNotification('info', message, title, detail, display_time, group);
348
},
349
340
- async success(message, title = "", detail = "", display_time = 3) {
341
- return await this.createNotification('success', message, title, detail, display_time);
350
+ async success(message, title = "", detail = "", display_time = 3, group = "") {
351
+ return await this.createNotification('success', message, title, detail, display_time, group);
352
},
353
344
- async warning(message, title = "", detail = "", display_time = 3) {
345
- return await this.createNotification('warning', message, title, detail, display_time);
354
+ async warning(message, title = "", detail = "", display_time = 3, group = "") {
355
+ return await this.createNotification('warning', message, title, detail, display_time, group);
356
},
357
348
- async error(message, title = "", detail = "", display_time = 3) {
349
- return await this.createNotification('error', message, title, detail, display_time);
358
+ async error(message, title = "", detail = "", display_time = 3, group = "") {
359
+ return await this.createNotification('error', message, title, detail, display_time, group);
360
},
361
352
- async progress(message, title = "", detail = "", display_time = 3) {
353
- return await this.createNotification('progress', message, title, detail, display_time);
362
+ async progress(message, title = "", detail = "", display_time = 3, group = "") {
363
+ return await this.createNotification('progress', message, title, detail, display_time, group);
364
},
365
366
// Enhanced: Open modal and clear toast stack
@@ -368,8 +378,24 @@ const model = {
378
this.openModal();
379
},
380
371
- // NEW: Add frontend-only toast directly to stack (for connection errors, etc.)
372
- addFrontendToast(type, message, title = "", display_time = 5) {
381
+ // NEW: Check if backend connection is available
382
+ isConnected() {
383
+ // Use the global connection status from index.js, but default to true if undefined
384
+ // This handles the case where polling hasn't run yet but backend is actually available
385
+ const pollingStatus = typeof window.getConnectionStatus === 'function' ? window.getConnectionStatus() : undefined;
386
+
387
+ // If polling status is explicitly false, respect that
388
+ if (pollingStatus === false) {
389
+ return false;
390
+ }
391
+
392
+ // If polling status is undefined/true, assume backend is available
393
+ // (since the page loaded successfully, backend must be working)
394
+ return true;
395
+ },
396
+
397
+ // NEW: Add frontend-only toast directly to stack (renamed from original addFrontendToast)
398
+ addFrontendToastOnly(type, message, title = "", display_time = 5, group = "") {
399
const timestamp = new Date().toISOString();
400
const notification = {
401
id: `frontend-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
@@ -380,9 +406,25 @@ const model = {
406
timestamp: timestamp,
407
display_time: display_time,
408
read: false,
383
- frontend: true // Mark as frontend-only
409
+ frontend: true, // Mark as frontend-only
410
+ group: group
411
};
412
413
+ // If notification has a group, remove any existing toasts with the same group
414
+ if (group && group.trim() !== "") {
415
+ const existingToastIndex = this.toastStack.findIndex(t =>
416
+ t.group === group
417
+ );
418
+
419
+ if (existingToastIndex >= 0) {
420
+ const existingToast = this.toastStack[existingToastIndex];
421
+ if (existingToast.autoRemoveTimer) {
422
+ clearTimeout(existingToast.autoRemoveTimer);
423
+ }
424
+ this.toastStack.splice(existingToastIndex, 1);
425
+ }
426
+ }
427
+
428
// Create toast object with auto-dismiss timer
429
const toast = {
430
...notification,
@@ -407,21 +449,41 @@ const model = {
449
this.removeFromToastStack(toast.toastId);
450
}, notification.display_time * 1000);
451
410
- console.log(`Frontend toast added: ${notification.type} - ${notification.message}`);
452
return notification.id;
453
},
454
414
- // NEW: Convenience methods for frontend-only notifications
415
- frontendError(message, title = "Connection Error", display_time = 8) {
416
- return this.addFrontendToast('error', message, title, display_time);
455
+ // NEW: Enhanced frontend toast that tries backend first, falls back to frontend-only
456
+ async addFrontendToast(type, message, title = "", display_time = 5, group = "") {
457
+ // Try to send to backend first if connected
458
+ if (this.isConnected()) {
459
+ try {
460
+ const notificationId = await this.createNotification(type, message, title, "", display_time, group);
461
+ if (notificationId) {
462
+ // Backend handled it, notification will arrive via polling
463
+ return notificationId;
464
+ }
465
+ } catch (error) {
466
+ console.log(`Backend unavailable for notification, showing as frontend-only: ${error.message || error}`);
467
+ }
468
+ } else {
469
+ console.log('Backend disconnected, showing as frontend-only toast');
470
+ }
471
+
472
+ // Fallback to frontend-only toast
473
+ return this.addFrontendToastOnly(type, message, title, display_time, group);
474
},
475
419
- frontendWarning(message, title = "Warning", display_time = 5) {
420
- return this.addFrontendToast('warning', message, title, display_time);
476
+ // NEW: Convenience methods for frontend notifications (updated to use new backend-first logic)
477
+ async frontendError(message, title = "Connection Error", display_time = 8, group = "") {
478
+ return await this.addFrontendToast('error', message, title, display_time, group);
479
},
480
423
- frontendInfo(message, title = "Info", display_time = 3) {
424
- return this.addFrontendToast('info', message, title, display_time);
481
+ async frontendWarning(message, title = "Warning", display_time = 5, group = "") {
482
+ return await this.addFrontendToast('warning', message, title, display_time, group);
483
+ },
484
+
485
+ async frontendInfo(message, title = "Info", display_time = 3, group = "") {
486
+ return await this.addFrontendToast('info', message, title, display_time, group);
487
}
488
};
489
@@ -429,9 +491,16 @@ const model = {
491
const store = createStore("notificationStore", model);
492
493
// NEW: Global function for frontend error toasts (replaces toastFetchError)
432
-window.toastFrontendError = function(message, title = "Connection Error") {
494
+window.toastFrontendError = async function(message, title = "Connection Error") {
495
if (window.Alpine && window.Alpine.store && window.Alpine.store('notificationStore')) {
434
- return window.Alpine.store('notificationStore').frontendError(message, title);
496
+ try {
497
+ return await window.Alpine.store('notificationStore').frontendError(message, title);
498
+ } catch (error) {
499
+ console.error('Failed to create frontend error notification:', error);
500
+ // Fallback to console if something goes wrong
501
+ console.error('Frontend Error:', title, '-', message);
502
+ return null;
503
+ }
504
} else {
505
// Fallback if Alpine/store not ready
506
console.error('Frontend Error:', title, '-', message);
@@ -440,18 +509,30 @@ window.toastFrontendError = function(message, title = "Connection Error") {
509
};
510
511
// NEW: Additional global convenience functions
443
-window.toastFrontendWarning = function(message, title = "Warning") {
512
+window.toastFrontendWarning = async function(message, title = "Warning") {
513
if (window.Alpine && window.Alpine.store && window.Alpine.store('notificationStore')) {
445
- return window.Alpine.store('notificationStore').frontendWarning(message, title);
514
+ try {
515
+ return await window.Alpine.store('notificationStore').frontendWarning(message, title);
516
+ } catch (error) {
517
+ console.error('Failed to create frontend warning notification:', error);
518
+ console.warn('Frontend Warning:', title, '-', message);
519
+ return null;
520
+ }
521
} else {
522
console.warn('Frontend Warning:', title, '-', message);
523
return null;
524
}
525
};
526
452
-window.toastFrontendInfo = function(message, title = "Info") {
527
+window.toastFrontendInfo = async function(message, title = "Info") {
528
if (window.Alpine && window.Alpine.store && window.Alpine.store('notificationStore')) {
454
- return window.Alpine.store('notificationStore').frontendInfo(message, title);
529
+ try {
530
+ return await window.Alpine.store('notificationStore').frontendInfo(message, title);
531
+ } catch (error) {
532
+ console.error('Failed to create frontend info notification:', error);
533
+ console.log('Frontend Info:', title, '-', message);
534
+ return null;
535
+ }
536
} else {
537
console.log('Frontend Info:', title, '-', message);
538
return null;
webui/js/scheduler.js
+21
-5
@@ -59,11 +59,27 @@ import { switchFromContext } from '../index.js';
59
60
// Add this near the top of the scheduler.js file, outside of any function
61
const showToast = function(message, type = 'info') {
62
- // Use the global toast function if available, otherwise fallback to console
63
- if (typeof window.toast === 'function') {
64
- window.toast(message, type);
62
+ // Use new frontend notification system
63
+ if (window.Alpine && window.Alpine.store && window.Alpine.store('notificationStore')) {
64
+ const store = window.Alpine.store('notificationStore');
65
+ switch (type.toLowerCase()) {
66
+ case 'error':
67
+ return store.frontendError(message, "Scheduler", 5);
68
+ case 'success':
69
+ return store.frontendInfo(message, "Scheduler", 3);
70
+ case 'warning':
71
+ return store.frontendWarning(message, "Scheduler", 4);
72
+ case 'info':
73
+ default:
74
+ return store.frontendInfo(message, "Scheduler", 3);
75
+ }
76
} else {
66
- console.log(`Toast (${type}): ${message}`);
77
+ // Fallback to global toast function or console
78
+ if (typeof window.toast === 'function') {
79
+ window.toast(message, type);
80
+ } else {
81
+ console.log(`SCHEDULER ${type.toUpperCase()}: ${message}`);
82
+ }
83
}
84
};
85
@@ -976,7 +992,7 @@ const fullComponentImplementation = function() {
992
}
993
994
showToast('Task deleted successfully', 'success');
979
-
995
+
996
// If we were viewing the detail of the deleted task, close the detail view
997
if (this.selectedTaskForDetail && this.selectedTaskForDetail.uuid === taskId) {
998
this.closeTaskDetail();
webui/js/settings.js
+20
-19
@@ -575,24 +575,25 @@ document.addEventListener('alpine:init', function () {
575
});
576
});
577
578
-// Show toast notification
578
+// Show toast notification - now uses new notification system
579
function showToast(message, type = 'info') {
580
- const toast = document.createElement('div');
581
- toast.className = `toast toast-${type}`;
582
- toast.textContent = message;
583
-
584
- document.body.appendChild(toast);
585
-
586
- // Trigger animation
587
- setTimeout(() => {
588
- toast.classList.add('show');
589
- }, 10);
590
-
591
- // Remove after delay
592
- setTimeout(() => {
593
- toast.classList.remove('show');
594
- setTimeout(() => {
595
- document.body.removeChild(toast);
596
- }, 300);
597
- }, 3000);
580
+ // Use new frontend notification system based on type
581
+ if (window.Alpine && window.Alpine.store && window.Alpine.store('notificationStore')) {
582
+ const store = window.Alpine.store('notificationStore');
583
+ switch (type.toLowerCase()) {
584
+ case 'error':
585
+ return store.frontendError(message, "Settings", 5);
586
+ case 'success':
587
+ return store.frontendInfo(message, "Settings", 3);
588
+ case 'warning':
589
+ return store.frontendWarning(message, "Settings", 4);
590
+ case 'info':
591
+ default:
592
+ return store.frontendInfo(message, "Settings", 3);
593
+ }
594
+ } else {
595
+ // Fallback if Alpine/store not ready
596
+ console.log(`SETTINGS ${type.toUpperCase()}: ${message}`);
597
+ return null;
598
+ }
599
}
webui/js/speech.js
+4
-7
@@ -47,7 +47,7 @@ async function loadMicSettings() {
47
});
48
}
49
} catch (error) {
50
- window.toastFetchError("Failed to load speech settings", error);
50
+ window.toastFrontendError("Failed to load speech settings: " + error.message, "Speech Settings Error");
51
console.error("Failed to load speech settings:", error);
52
}
53
}
@@ -205,7 +205,7 @@ class MicrophoneInput {
205
return true;
206
} catch (error) {
207
console.error("Microphone initialization error:", error);
208
- toast("Failed to access microphone. Please check permissions.", "error");
208
+ window.toastFrontendError("Failed to access microphone. Please check permissions.", "Microphone Error");
209
return false;
210
}
211
}
@@ -295,7 +295,7 @@ class MicrophoneInput {
295
await this.updateCallback(result.text, true);
296
}
297
} catch (error) {
298
- window.toastFetchError("Transcription error", error);
298
+ window.toastFrontendError("Transcription error: " + error.message, "Speech Recognition Error");
299
console.error("Transcription error:", error);
300
} finally {
301
this.audioChunks = [];
@@ -386,10 +386,7 @@ async function requestMicrophonePermission() {
386
return true;
387
} catch (err) {
388
console.error("Error accessing microphone:", err);
389
- toast(
390
- "Microphone access denied. Please enable microphone access in your browser settings.",
391
- "error"
392
- );
389
+ window.toastFrontendError("Microphone access denied. Please enable microphone access in your browser settings.", "Microphone Error");
390
return false;
391
}
392
}
webui/js/speech_browser.js
+6
-6
@@ -178,7 +178,7 @@ class MicrophoneInput {
178
} catch (error) {
179
180
console.error('Microphone initialization error:', error);
181
- toast('Failed to access microphone. Please check permissions.', 'error');
181
+ window.toastFrontendError('Failed to access microphone. Please check permissions.', 'Microphone Error');
182
return false;
183
}
184
}
@@ -193,7 +193,7 @@ class MicrophoneInput {
193
this.analyserNode.smoothingTimeConstant = 0.85;
194
this.mediaStreamSource.connect(this.analyserNode);
195
}
196
-
196
+
197
198
startAudioAnalysis() {
199
const analyzeFrame = () => {
@@ -251,7 +251,7 @@ class MicrophoneInput {
251
return;
252
}
253
254
- const audioBlob = new Blob(this.audioChunks, { type: 'audio/wav' });
254
+ const audioBlob = new Blob(this.audioChunks, { type: 'audio/wav' });
255
const audioUrl = URL.createObjectURL(audioBlob);
256
257
@@ -268,7 +268,7 @@ class MicrophoneInput {
268
}
269
} catch (error) {
270
console.error('Transcription error:', error);
271
- toast('Transcription failed.', 'error');
271
+ window.toastFrontendError('Transcription failed.', 'Speech Recognition Error');
272
} finally {
273
URL.revokeObjectURL(audioUrl);
274
this.audioChunks = [];
@@ -347,7 +347,7 @@ async function requestMicrophonePermission() {
347
return true;
348
} catch (err) {
349
console.error('Error accessing microphone:', err);
350
- toast('Microphone access denied. Please enable microphone access in your browser settings.', 'error');
350
+ window.toastFrontendError('Microphone access denied. Please enable microphone access in your browser settings.', 'Microphone Error');
351
return false;
352
}
353
}
@@ -391,4 +391,4 @@ class Speech {
391
}
392
393
export const speech = new Speech();
394
-window.speech = speech
\ No newline at end of file
394
+window.speech = speech
webui/js/tunnel.js
+57
-54
@@ -24,9 +24,9 @@ document.addEventListener('alpine:init', () => {
24
},
25
body: JSON.stringify({ action: 'get' }),
26
});
27
-
27
+
28
const data = await response.json();
29
-
29
+
30
if (data.success && data.tunnel_url) {
31
// Update the stored URL if it's different from what we have
32
if (this.tunnelLink !== data.tunnel_url) {
@@ -37,7 +37,7 @@ document.addEventListener('alpine:init', () => {
37
} else {
38
// Check if we have a stored tunnel URL
39
const storedTunnelUrl = localStorage.getItem('agent_zero_tunnel_url');
40
-
40
+
41
if (storedTunnelUrl) {
42
// Use the stored URL but verify it's still valid
43
const verifyResponse = await fetchApi('/tunnel_proxy', {
@@ -47,9 +47,9 @@ document.addEventListener('alpine:init', () => {
47
},
48
body: JSON.stringify({ action: 'verify', url: storedTunnelUrl }),
49
});
50
-
50
+
51
const verifyData = await verifyResponse.json();
52
-
52
+
53
if (verifyData.success && verifyData.is_valid) {
54
this.tunnelLink = storedTunnelUrl;
55
this.linkGenerated = true;
@@ -77,14 +77,14 @@ document.addEventListener('alpine:init', () => {
77
if (confirm("Are you sure you want to generate a new tunnel URL? The old URL will no longer work.")) {
78
this.isLoading = true;
79
this.loadingText = 'Refreshing tunnel...';
80
-
80
+
81
// Change refresh button appearance
82
const refreshButton = document.querySelector('.refresh-link-button');
83
const originalContent = refreshButton.innerHTML;
84
refreshButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Refreshing...';
85
refreshButton.disabled = true;
86
refreshButton.classList.add('refreshing');
87
-
87
+
88
try {
89
// First stop any existing tunnel
90
const stopResponse = await fetchApi('/tunnel_proxy', {
@@ -94,19 +94,19 @@ document.addEventListener('alpine:init', () => {
94
},
95
body: JSON.stringify({ action: 'stop' }),
96
});
97
-
97
+
98
// Check if stopping was successful
99
const stopData = await stopResponse.json();
100
if (!stopData.success) {
101
console.warn("Warning: Couldn't stop existing tunnel cleanly");
102
// Continue anyway since we want to create a new one
103
}
104
-
104
+
105
// Then generate a new one
106
await this.generateLink();
107
} catch (error) {
108
console.error("Error refreshing tunnel:", error);
109
- window.toast("Error refreshing tunnel", "error", 3000);
109
+ window.toastFrontendError("Error refreshing tunnel", "Tunnel Error");
110
this.isLoading = false;
111
this.loadingText = '';
112
} finally {
@@ -123,17 +123,17 @@ document.addEventListener('alpine:init', () => {
123
try {
124
const authCheckResponse = await fetchApi('/settings_get');
125
const authData = await authCheckResponse.json();
126
-
126
+
127
// Find the auth_login and auth_password in the settings
128
let hasAuth = false;
129
-
129
+
130
if (authData && authData.settings && authData.settings.sections) {
131
for (const section of authData.settings.sections) {
132
if (section.fields) {
133
const authLoginField = section.fields.find(field => field.id === 'auth_login');
134
const authPasswordField = section.fields.find(field => field.id === 'auth_password');
135
-
136
- if (authLoginField && authPasswordField &&
135
+
136
+ if (authLoginField && authPasswordField &&
137
authLoginField.value && authPasswordField.value) {
138
hasAuth = true;
139
break;
@@ -141,7 +141,7 @@ document.addEventListener('alpine:init', () => {
141
}
142
}
143
}
144
-
144
+
145
// If no authentication is set, warn the user
146
if (!hasAuth) {
147
const proceed = confirm(
@@ -152,7 +152,7 @@ document.addEventListener('alpine:init', () => {
152
"before creating a public tunnel.\n\n" +
153
"Do you want to proceed anyway?"
154
);
155
-
155
+
156
if (!proceed) {
157
return; // User cancelled
158
}
@@ -161,13 +161,16 @@ document.addEventListener('alpine:init', () => {
161
console.error("Error checking authentication status:", error);
162
// Continue anyway if we can't check auth status
163
}
164
-
164
+
165
this.isLoading = true;
166
this.loadingText = 'Creating tunnel...';
167
168
+ // Get provider from the parent settings modal scope
169
+ const modalEl = document.getElementById('settingsModal');
170
+ const modalAD = Alpine.$data(modalEl);
171
// Use the local provider setting
172
const provider = this.provider || 'serveo'; // Default to serveo if not set
170
-
173
+
174
// Change create button appearance
175
const createButton = document.querySelector('.tunnel-actions .btn-ok');
176
if (createButton) {
@@ -175,7 +178,7 @@ document.addEventListener('alpine:init', () => {
178
createButton.disabled = true;
179
createButton.classList.add('creating');
180
}
178
-
181
+
182
try {
183
// Call the backend API to create a tunnel
184
const response = await fetchApi('/tunnel_proxy', {
@@ -183,31 +186,31 @@ document.addEventListener('alpine:init', () => {
186
headers: {
187
'Content-Type': 'application/json',
188
},
186
- body: JSON.stringify({
189
+ body: JSON.stringify({
190
action: 'create',
191
provider: provider
192
// port: window.location.port || (window.location.protocol === 'https:' ? 443 : 80)
193
}),
194
});
192
-
195
+
196
const data = await response.json();
194
-
197
+
198
if (data.success && data.tunnel_url) {
199
// Store the tunnel URL in localStorage for persistence
200
localStorage.setItem('agent_zero_tunnel_url', data.tunnel_url);
198
-
201
+
202
this.tunnelLink = data.tunnel_url;
203
this.linkGenerated = true;
201
-
204
+
205
// Show success message to confirm creation
203
- window.toast("Tunnel created successfully", "success", 3000);
206
+ window.toastFrontendInfo("Tunnel created successfully", "Tunnel Status");
207
} else {
208
// The tunnel might still be starting up, check again after a delay
209
this.loadingText = 'Tunnel creation taking longer than expected...';
207
-
210
+
211
// Wait for 5 seconds and check if the tunnel is running
212
await new Promise(resolve => setTimeout(resolve, 5000));
210
-
213
+
214
// Check if tunnel is running now
215
try {
216
const statusResponse = await fetchApi('/tunnel_proxy', {
@@ -217,33 +220,33 @@ document.addEventListener('alpine:init', () => {
220
},
221
body: JSON.stringify({ action: 'get' }),
222
});
220
-
223
+
224
const statusData = await statusResponse.json();
222
-
225
+
226
if (statusData.success && statusData.tunnel_url) {
227
// Tunnel is now running, we can update the UI
228
localStorage.setItem('agent_zero_tunnel_url', statusData.tunnel_url);
229
this.tunnelLink = statusData.tunnel_url;
230
this.linkGenerated = true;
228
- window.toast("Tunnel created successfully", "success", 3000);
231
+ window.toastFrontendInfo("Tunnel created successfully", "Tunnel Status");
232
return;
233
}
234
} catch (statusError) {
235
console.error("Error checking tunnel status:", statusError);
236
}
234
-
237
+
238
// If we get here, the tunnel really failed to start
239
const errorMessage = data.message || "Failed to create tunnel. Please try again.";
237
- window.toast(errorMessage, "error", 5000);
240
+ window.toastFrontendError(errorMessage, "Tunnel Error");
241
console.error("Tunnel creation failed:", data);
242
}
243
} catch (error) {
241
- window.toast("Error creating tunnel", "error", 5000);
244
+ window.toastFrontendError("Error creating tunnel", "Tunnel Error");
245
console.error("Error creating tunnel:", error);
246
} finally {
247
this.isLoading = false;
248
this.loadingText = '';
246
-
249
+
250
// Reset create button if it's still in the DOM
251
const createButton = document.querySelector('.tunnel-actions .btn-ok');
252
if (createButton) {
@@ -258,8 +261,8 @@ document.addEventListener('alpine:init', () => {
261
if (confirm("Are you sure you want to stop the tunnel? The URL will no longer be accessible.")) {
262
this.isLoading = true;
263
this.loadingText = 'Stopping tunnel...';
261
-
262
-
264
+
265
+
266
try {
267
// Call the backend to stop the tunnel
268
const response = await fetchApi('/tunnel_proxy', {
@@ -269,30 +272,30 @@ document.addEventListener('alpine:init', () => {
272
},
273
body: JSON.stringify({ action: 'stop' }),
274
});
272
-
275
+
276
const data = await response.json();
274
-
277
+
278
if (data.success) {
279
// Clear the stored URL
280
localStorage.removeItem('agent_zero_tunnel_url');
278
-
281
+
282
// Update UI state
283
this.tunnelLink = '';
284
this.linkGenerated = false;
282
-
283
- window.toast("Tunnel stopped successfully", "success", 3000);
285
+
286
+ window.toastFrontendInfo("Tunnel stopped successfully", "Tunnel Status");
287
} else {
285
- window.toast("Failed to stop tunnel", "error", 3000);
286
-
288
+ window.toastFrontendError("Failed to stop tunnel", "Tunnel Error");
289
+
290
// Reset stop button
291
stopButton.innerHTML = originalStopContent;
292
stopButton.disabled = false;
293
stopButton.classList.remove('stopping');
294
}
295
} catch (error) {
293
- window.toast("Error stopping tunnel", "error", 3000);
296
+ window.toastFrontendError("Error stopping tunnel", "Tunnel Error");
297
console.error("Error stopping tunnel:", error);
295
-
298
+
299
// Reset stop button
300
stopButton.innerHTML = originalStopContent;
301
stopButton.disabled = false;
@@ -306,19 +309,19 @@ document.addEventListener('alpine:init', () => {
309
310
copyToClipboard() {
311
if (!this.tunnelLink) return;
309
-
312
+
313
const copyButton = document.querySelector('.copy-link-button');
314
const originalContent = copyButton.innerHTML;
312
-
315
+
316
navigator.clipboard.writeText(this.tunnelLink)
317
.then(() => {
318
// Update button to show success state
319
copyButton.innerHTML = '<i class="fas fa-check"></i> Copied!';
320
copyButton.classList.add('copy-success');
318
-
321
+
322
// Show toast notification
320
- window.toast("Tunnel URL copied to clipboard!", "success", 3000);
321
-
323
+ window.toastFrontendInfo("Tunnel URL copied to clipboard!", "Clipboard");
324
+
325
// Reset button after 2 seconds
326
setTimeout(() => {
327
copyButton.innerHTML = originalContent;
@@ -327,12 +330,12 @@ document.addEventListener('alpine:init', () => {
330
})
331
.catch(err => {
332
console.error('Failed to copy URL: ', err);
330
- window.toast("Failed to copy tunnel URL", "error", 3000);
331
-
333
+ window.toastFrontendError("Failed to copy tunnel URL", "Clipboard Error");
334
+
335
// Show error state
336
copyButton.innerHTML = '<i class="fas fa-times"></i> Failed';
337
copyButton.classList.add('copy-error');
335
-
338
+
339
// Reset button after 2 seconds
340
setTimeout(() => {
341
copyButton.innerHTML = originalContent;
@@ -341,4 +344,4 @@ document.addEventListener('alpine:init', () => {
344
});
345
}
346
}));
344
-});
\ No newline at end of file
347
+});