notifications polishing
frdel committed
Aug 4, 2025 at 13:07 UTC
97e009dbe5849c6b3e784afbca7f58d3118b17dc
9 files changed
+351
-268
python/api/notification_create.py
+18
-17
@@ -1,6 +1,6 @@
1
from python.helpers.api import ApiHandler
2
from flask import Request, Response
3
-from python.helpers.notification import AgentNotification, NotificationType
3
+from python.helpers.notification import NotificationManager, NotificationPriority, NotificationType
4
5
6
class NotificationCreate(ApiHandler):
@@ -10,7 +10,8 @@ class NotificationCreate(ApiHandler):
10
11
async def process(self, input: dict, request: Request) -> dict | Response:
12
# Extract notification data
13
- notification_type = input.get("type", "info")
13
+ notification_type = input.get("type", NotificationType.INFO.value)
14
+ priority = input.get("priority", NotificationPriority.NORMAL.value)
15
message = input.get("message", "")
16
title = input.get("title", "")
17
detail = input.get("detail", "")
@@ -34,31 +35,31 @@ class NotificationCreate(ApiHandler):
35
if isinstance(notification_type, str):
36
notification_type = NotificationType(notification_type.lower())
37
except ValueError:
37
- return {"success": False, "error": f"Invalid notification type: {notification_type}"}
38
+ return {
39
+ "success": False,
40
+ "error": f"Invalid notification type: {notification_type}",
41
+ }
42
43
# Create notification using the appropriate helper method
44
try:
41
- if notification_type == NotificationType.INFO:
42
- notification = AgentNotification.info(message, title, detail, display_time, group)
43
- elif notification_type == NotificationType.SUCCESS:
44
- notification = AgentNotification.success(message, title, detail, display_time, group)
45
- elif notification_type == NotificationType.WARNING:
46
- notification = AgentNotification.warning(message, title, detail, display_time, group)
47
- elif notification_type == NotificationType.ERROR:
48
- notification = AgentNotification.error(message, title, detail, display_time, group)
49
- elif notification_type == NotificationType.PROGRESS:
50
- notification = AgentNotification.progress(message, title, detail, display_time, group)
51
- else:
52
- notification = AgentNotification.info(message, title, detail, display_time, group)
45
+ notification = NotificationManager.send_notification(
46
+ notification_type,
47
+ priority,
48
+ message,
49
+ title,
50
+ detail,
51
+ display_time,
52
+ group,
53
+ )
54
55
return {
56
"success": True,
57
"notification_id": notification.id,
57
- "message": "Notification created successfully"
58
+ "message": "Notification created successfully",
59
}
60
61
except Exception as e:
62
return {
63
"success": False,
63
- "error": f"Failed to create notification: {str(e)}"
64
+ "error": f"Failed to create notification: {str(e)}",
65
}
python/api/notifications_clear.py
new
+18
@@ -0,0 +1,18 @@
1
+from python.helpers.api import ApiHandler
2
+from flask import Request, Response
3
+from agent import AgentContext
4
+
5
+
6
+class NotificationsClear(ApiHandler):
7
+ @classmethod
8
+ def requires_auth(cls) -> bool:
9
+ return True
10
+
11
+ async def process(self, input: dict, request: Request) -> dict | Response:
12
+ # Get the global notification manager
13
+ notification_manager = AgentContext.get_notification_manager()
14
+
15
+ # Clear all notifications
16
+ notification_manager.clear_all()
17
+
18
+ return {"success": True, "message": "All notifications cleared"}
python/helpers/notification.py
+26
-40
@@ -11,12 +11,17 @@ class NotificationType(Enum):
11
ERROR = "error"
12
PROGRESS = "progress"
13
14
+class NotificationPriority(Enum):
15
+ NORMAL = 10
16
+ HIGH = 20
17
+
18
19
@dataclass
20
class NotificationItem:
21
manager: "NotificationManager"
22
no: int
23
type: NotificationType
24
+ priority: NotificationPriority
25
title: str
26
message: str
27
detail: str # HTML content for expandable details
@@ -41,7 +46,8 @@ class NotificationItem:
46
return {
47
"no": self.no,
48
"id": self.id,
44
- "type": self.type.value,
49
+ "type": self.type.value if isinstance(self.type, NotificationType) else self.type,
50
+ "priority": self.priority.value if isinstance(self.priority, NotificationPriority) else self.priority,
51
"title": self.title,
52
"message": self.message,
53
"detail": self.detail,
@@ -59,9 +65,25 @@ class NotificationManager:
65
self.notifications: list[NotificationItem] = []
66
self.max_notifications = max_notifications
67
68
+ @staticmethod
69
+ def send_notification(
70
+ type: NotificationType,
71
+ priority: NotificationPriority,
72
+ message: str,
73
+ title: str = "",
74
+ detail: str = "",
75
+ display_time: int = 3,
76
+ group: str = "",
77
+ ) -> NotificationItem:
78
+ from agent import AgentContext
79
+ return AgentContext.get_notification_manager().add_notification(
80
+ type, priority, message, title, detail, display_time, group
81
+ )
82
+
83
def add_notification(
84
self,
85
type: NotificationType,
86
+ priority: NotificationPriority,
87
message: str,
88
title: str = "",
89
detail: str = "",
@@ -72,7 +94,8 @@ class NotificationManager:
94
item = NotificationItem(
95
manager=self,
96
no=len(self.notifications),
75
- type=type,
97
+ type=NotificationType(type),
98
+ priority=NotificationPriority(priority),
99
title=title,
100
message=message,
101
detail=detail,
@@ -138,41 +161,4 @@ class NotificationManager:
161
self.guid = str(uuid.uuid4())
162
163
def get_notifications_by_type(self, type: NotificationType) -> list[NotificationItem]:
141
- return [n for n in self.notifications if n.type == type]
142
-
143
-
144
-class AgentNotification:
145
- @staticmethod
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(
149
- NotificationType.INFO, message, title, detail, display_time, group
150
- )
151
-
152
- @staticmethod
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(
156
- NotificationType.SUCCESS, message, title, detail, display_time, group
157
- )
158
-
159
- @staticmethod
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(
163
- NotificationType.WARNING, message, title, detail, display_time, group
164
- )
165
-
166
- @staticmethod
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(
170
- NotificationType.ERROR, message, title, detail, display_time, group
171
- )
172
-
173
- @staticmethod
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(
177
- NotificationType.PROGRESS, message, title, detail, display_time, group
178
- )
164
+ return [n for n in self.notifications if n.type == type]
\ No newline at end of file
python/tools/notify_user.py
+14
-3
@@ -1,6 +1,6 @@
1
from python.helpers.tool import Tool, Response
2
from agent import AgentContext
3
-
3
+from python.helpers.notification import NotificationPriority, NotificationType
4
5
class NotifyUserTool(Tool):
6
@@ -9,11 +9,20 @@ class NotifyUserTool(Tool):
9
message = self.args.get("message", "")
10
title = self.args.get("title", "")
11
detail = self.args.get("detail", "")
12
- notification_type = self.args.get("type", "info")
12
+ notification_type = self.args.get("type", NotificationType.INFO)
13
+ priority = self.args.get("priority", NotificationPriority.HIGH) # by default, agents should notify with high priority
14
+ timeout = int(self.args.get("timeout", 30)) # agent's notifications should have longer timeouts
15
14
- if notification_type not in ["info", "success", "warning", "error", "progress"]:
16
+ try:
17
+ notification_type = NotificationType(notification_type)
18
+ except ValueError:
19
return Response(message=f"Invalid notification type: {notification_type}", break_loop=False)
20
21
+ try:
22
+ priority = NotificationPriority(priority)
23
+ except ValueError:
24
+ return Response(message=f"Invalid notification priority: {priority}", break_loop=False)
25
+
26
if not message:
27
return Response(message="Message is required", break_loop=False)
28
@@ -22,5 +31,7 @@ class NotifyUserTool(Tool):
31
title=title,
32
detail=detail,
33
type=notification_type,
34
+ priority=priority,
35
+ display_time=timeout,
36
)
37
return Response(message=self.agent.read_prompt("fw.notify_user.notification_sent.md"), break_loop=False)
webui/components/notifications/notification-icons.html
+3
-3
@@ -10,7 +10,7 @@
10
<!-- Notification Toggle Button -->
11
<div class="notification-toggle"
12
:class="{
13
- 'has-unread': $store.notificationStore.unreadCount > 0,
13
+ 'has-unread': $store.notificationStore.unreadPrioCount > 0,
14
'has-notifications': $store.notificationStore.notifications.length > 0
15
}"
16
@click="$store.notificationStore.openModal()"
@@ -18,9 +18,9 @@
18
<div class="notification-icon">
19
<span class="material-symbols-outlined">notifications</span>
20
</div>
21
- <span x-show="$store.notificationStore.unreadCount > 0"
21
+ <span x-show="$store.notificationStore.unreadPrioCount > 0"
22
class="notification-badge"
23
- x-text="$store.notificationStore.unreadCount"></span>
23
+ x-text="$store.notificationStore.unreadPrioCount"></span>
24
</div>
25
</template>
26
</div>
webui/components/notifications/notification-modal.html
+76
-112
@@ -1,84 +1,82 @@
1
<html>
2
+
3
<head>
4
<title>Notifications</title>
5
<script type="module">
6
import { store } from "/components/notifications/notification-store.js";
7
</script>
8
</head>
9
+
10
<body>
9
- <div x-data="notificationModalComponent()">
10
- <!-- Modal Header Actions -->
11
- <div class="modal-subheader">
12
- <div class="notification-header-actions">
13
- <button class="notification-action"
14
- @click="$store.notificationStore?.clearAll()"
15
- :disabled="!$store.notificationStore || $store.notificationStore.getDisplayNotifications().length === 0"
16
- title="Clear All">
17
- <span class="material-symbols-outlined">delete</span> Clear All
18
- </button>
19
- </div>
20
- </div>
21
-
22
- <!-- Notifications List -->
23
- <div class="notification-list" x-show="$store.notificationStore && $store.notificationStore.getDisplayNotifications().length > 0">
24
- <template x-for="notification in ($store.notificationStore?.getDisplayNotifications() || [])" :key="notification.id">
25
- <div class="notification-item"
26
- x-data="{ expanded: false }"
27
- :class="$store.notificationStore?.getNotificationItemClass(notification) || 'notification-item'"
28
- @click="$store.notificationStore?.markAsRead(notification.id)">
29
-
30
- <div class="notification-icon"
31
- x-html="$store.notificationStore?.getNotificationIcon(notification.type) || ''">
11
+ <div x-data>
12
+ <template x-if="$store.notificationStore">
13
+ <div>
14
+ <!-- Modal Header Actions -->
15
+ <div class="modal-subheader">
16
+ <div class="notification-header-actions">
17
+ <button class="notification-action" @click="$store.notificationStore?.clearAll()"
18
+ :disabled="!$store.notificationStore || $store.notificationStore.getDisplayNotifications().length === 0"
19
+ title="Clear All">
20
+ <span class="material-symbols-outlined">delete</span> Clear All
21
+ </button>
22
</div>
23
+ </div>
24
34
- <div class="notification-content">
35
- <div class="notification-title"
36
- x-show="notification.title"
37
- x-text="notification.title">
38
- </div>
39
- <div class="notification-message"
40
- x-text="notification.message">
41
- </div>
42
- <div class="notification-timestamp"
43
- x-text="$store.notificationStore?.formatTimestamp(notification.timestamp) || notification.timestamp">
25
+ <!-- Notifications List -->
26
+ <div class="notification-list"
27
+ x-show="$store.notificationStore && $store.notificationStore.getDisplayNotifications().length > 0">
28
+ <template x-for="notification in ($store.notificationStore?.getDisplayNotifications() || [])"
29
+ :key="notification.id">
30
+ <div class="notification-item" x-data="{ expanded: false }"
31
+ :class="$store.notificationStore?.getNotificationItemClass(notification) || 'notification-item'"
32
+ @click="$store.notificationStore?.markAsRead(notification.id)">
33
+
34
+ <div class="notification-icon"
35
+ x-html="$store.notificationStore?.getNotificationIcon(notification.type) || ''">
36
+ </div>
37
+
38
+ <div class="notification-content">
39
+ <div class="notification-title" x-show="notification.title" x-text="notification.title">
40
+ </div>
41
+ <div class="notification-message" x-text="notification.message">
42
+ </div>
43
+ <div class="notification-timestamp"
44
+ x-text="$store.notificationStore?.formatTimestamp(notification.timestamp) || notification.timestamp">
45
+ </div>
46
+
47
+ <!-- Expand Toggle Button (as last row element) -->
48
+ <button class="notification-expand-toggle" x-show="notification.detail"
49
+ @click.stop="expanded = !expanded"
50
+ :title="expanded ? 'Collapse Details' : 'Expand Details'">
51
+ <span x-show="!expanded">▶ Show Details</span>
52
+ <span x-show="expanded">▼ Hide Details</span>
53
+ </button>
54
+
55
+ <!-- Expandable Detail Content -->
56
+ <div class="notification-detail" x-show="expanded && notification.detail"
57
+ x-transition:enter="transition ease-out duration-200"
58
+ x-transition:enter-start="opacity-0 max-h-0"
59
+ x-transition:enter-end="opacity-100 max-h-96"
60
+ x-transition:leave="transition ease-in duration-200"
61
+ x-transition:leave-start="opacity-100 max-h-96"
62
+ x-transition:leave-end="opacity-0 max-h-0">
63
+ <div class="notification-detail-content" x-html="notification.detail"></div>
64
+ </div>
65
+ </div>
66
</div>
67
+ </template>
68
+ </div>
69
46
- <!-- Expand Toggle Button (as last row element) -->
47
- <button class="notification-expand-toggle"
48
- x-show="notification.detail"
49
- @click.stop="expanded = !expanded"
50
- :title="expanded ? 'Collapse Details' : 'Expand Details'">
51
- <span x-show="!expanded">▶ Show Details</span>
52
- <span x-show="expanded">▼ Hide Details</span>
53
- </button>
54
-
55
- <!-- Expandable Detail Content -->
56
- <div class="notification-detail"
57
- x-show="expanded && notification.detail"
58
- x-transition:enter="transition ease-out duration-200"
59
- x-transition:enter-start="opacity-0 max-h-0"
60
- x-transition:enter-end="opacity-100 max-h-96"
61
- x-transition:leave="transition ease-in duration-200"
62
- x-transition:leave-start="opacity-100 max-h-96"
63
- x-transition:leave-end="opacity-0 max-h-0">
64
- <div class="notification-detail-content" x-html="notification.detail"></div>
65
- </div>
70
+ <!-- Empty State -->
71
+ <div class="notification-empty"
72
+ x-show="!$store.notificationStore || $store.notificationStore.getDisplayNotifications().length === 0">
73
+ <div class="notification-empty-icon">
74
+ <span class="material-symbols-outlined">notifications</span>
75
</div>
76
+ <p>No notifications to display</p>
77
</div>
68
- </template>
69
- </div>
70
-
71
- <!-- Empty State -->
72
- <div class="notification-empty" x-show="!$store.notificationStore || $store.notificationStore.getDisplayNotifications().length === 0">
73
- <div class="notification-empty-icon">
74
- <span class="material-symbols-outlined">notifications</span>
78
</div>
76
- <p>No notifications to display</p>
77
- <p style="font-size: 0.8rem; opacity: 0.7; margin-top: 0.5rem;"
78
- x-show="$store.notificationStore && $store.notificationStore.notifications.length > 0">
79
- All notifications have been read and are older than 5 minutes
80
- </p>
81
- </div>
79
+ </template>
80
</div>
81
82
<style>
@@ -175,12 +173,13 @@
173
}
174
175
.notification-item.unread {
178
- border-left: 4px solid #2196F3;
179
- background: rgba(33, 150, 243, 0.05);
176
+ border-left: 4px solid var(--color-primary);
177
+ background: var(--color-panel);
178
}
179
180
.notification-item.read {
183
- opacity: 0.7;
181
+ opacity: 0.85;
182
+ background: var(--color-panel);
183
}
184
185
.notification-item.read .notification-title {
@@ -303,51 +302,16 @@
302
303
/* Animations */
304
@keyframes spin {
306
- from { transform: rotate(0deg); }
307
- to { transform: rotate(360deg); }
308
- }
309
-
310
- /* Light Mode Styles */
311
- .light-mode .notification-item {
312
- background: rgba(0, 0, 0, 0.03);
313
- border-color: rgba(0, 0, 0, 0.1);
314
- }
305
+ from {
306
+ transform: rotate(0deg);
307
+ }
308
316
- .light-mode .notification-item:hover {
317
- background: rgba(0, 0, 0, 0.06);
318
- border-color: rgba(0, 0, 0, 0.15);
319
- }
320
-
321
- .light-mode .notification-action {
322
- border-color: rgba(0, 0, 0, 0.2);
323
- color: var(--color-text);
324
- }
325
-
326
- .light-mode .notification-action:hover {
327
- background: rgba(0, 0, 0, 0.05);
328
- border-color: rgba(0, 0, 0, 0.3);
329
- }
330
-
331
- .light-mode .notification-detail {
332
- background: rgba(0, 0, 0, 0.05);
333
- border-left-color: rgba(0, 0, 0, 0.15);
309
+ to {
310
+ transform: rotate(360deg);
311
+ }
312
}
313
</style>
314
337
- <script>
338
- function notificationModalComponent() {
339
- return {
340
- init() {
341
- // Mark all notifications as read when modal opens
342
- // This can be configured later if needed
343
- setTimeout(() => {
344
- if (this.$store.notificationStore) {
345
- this.$store.notificationStore.markAllAsRead();
346
- }
347
- }, 1000);
348
- }
349
- };
350
- }
351
- </script>
315
</body>
353
-</html>
316
+
317
+</html>
\ No newline at end of file
webui/components/notifications/notification-store.js
+181
-89
@@ -1,5 +1,24 @@
1
import { createStore } from "/js/AlpineStore.js";
2
import * as API from "/js/api.js";
3
+import { openModal } from "/js/modals.js";
4
+
5
+export const NotificationType = {
6
+ INFO: "info",
7
+ SUCCESS: "success",
8
+ WARNING: "warning",
9
+ ERROR: "error",
10
+ PROGRESS: "progress",
11
+};
12
+
13
+export const NotificationPriority = {
14
+ NORMAL: 10,
15
+ HIGH: 20,
16
+};
17
+
18
+export const defaultPriority = NotificationPriority.NORMAL;
19
+
20
+const maxNotifications = 100
21
+const maxToasts = 5
22
23
const model = {
24
notifications: [],
@@ -7,33 +26,27 @@ const model = {
26
lastNotificationVersion: 0,
27
lastNotificationGuid: "",
28
unreadCount: 0,
29
+ unreadPrioCount: 0,
30
31
// NEW: Toast stack management
32
toastStack: [],
13
- maxToastStack: 5,
14
-
33
+
34
init() {
35
this.initialize();
17
-
18
- setTimeout(function () {
19
- this.frontendInfo("Notification store initialized 1");
20
- // this.frontendInfo("Notification store initialized 2");
21
- // this.frontendInfo("Notification store initialized 3");
22
- }.bind(this), 100);
36
},
37
38
// Initialize the notification store
39
initialize() {
40
this.loading = true;
41
this.updateUnreadCount();
29
- this.removeOldNotifications();
42
+ // this.removeOldNotifications();
43
this.toastStack = [];
44
32
- // Auto-cleanup old notifications and toasts
33
- setInterval(() => {
34
- this.removeOldNotifications();
35
- this.cleanupExpiredToasts();
36
- }, 5 * 60 * 1000); // Every 5 minutes
45
+ // // Auto-cleanup old notifications and toasts
46
+ // setInterval(() => {
47
+ // this.removeOldNotifications();
48
+ // this.cleanupExpiredToasts();
49
+ // }, 5 * 60 * 1000); // Every 5 minutes
50
},
51
52
// Update notifications from polling data
@@ -51,11 +64,17 @@ const model = {
64
// Process new notifications and add to toast stack
65
if (pollData.notifications && pollData.notifications.length > 0) {
66
pollData.notifications.forEach((notification) => {
67
+ // should we toast the notification?
68
+ const shouldToast = !notification.read;
69
+
70
+ // adjust notification data before adding
71
+ this.adjustNotificationData(notification);
72
+
73
const isNew = !this.notifications.find((n) => n.id === notification.id);
74
this.addOrUpdateNotification(notification);
75
76
// Add new unread notifications to toast stack
58
- if (isNew && !notification.read) {
77
+ if (isNew && shouldToast) {
78
this.addToToastStack(notification);
79
}
80
});
@@ -68,14 +87,12 @@ const model = {
87
// Update UI state
88
this.updateUnreadCount();
89
// this.removeOldNotifications();
90
+ },
91
72
- // Limit notifications to prevent memory issues (keep most recent)
73
- if (this.notifications.length > 50) {
74
- // Sort by timestamp and keep newest 50
75
- this.notifications.sort(
76
- (a, b) => new Date(b.timestamp) - new Date(a.timestamp)
77
- );
78
- this.notifications = this.notifications.slice(0, 50);
92
+ adjustNotificationData(notification) {
93
+ // set default priority if not set
94
+ if (!notification.priority) {
95
+ notification.priority = defaultPriority;
96
}
97
},
98
@@ -83,17 +100,11 @@ const model = {
100
addToToastStack(notification) {
101
// If notification has a group, remove any existing toasts with the same group
102
if (notification.group && notification.group.trim() !== "") {
86
- const existingToastIndex = this.toastStack.findIndex(
103
+ const existingToast = this.toastStack.find(
104
(t) => t.group === notification.group
105
);
89
-
90
- if (existingToastIndex >= 0) {
91
- const existingToast = this.toastStack[existingToastIndex];
92
- if (existingToast.autoRemoveTimer) {
93
- clearTimeout(existingToast.autoRemoveTimer);
94
- }
95
- this.toastStack.splice(existingToastIndex, 1);
96
- }
106
+ if (existingToast && existingToast.toastId)
107
+ this.removeFromToastStack(existingToast.toastId);
108
}
109
110
// Create toast object with auto-dismiss timer
@@ -107,12 +118,10 @@ const model = {
118
// Add to bottom of stack (newest at bottom)
119
this.toastStack.push(toast);
120
110
- // Enforce max stack limit (remove oldest from top)
111
- if (this.toastStack.length > this.maxToastStack) {
112
- const removed = this.toastStack.shift(); // Remove from top
113
- if (removed.autoRemoveTimer) {
114
- clearTimeout(removed.autoRemoveTimer);
115
- }
121
+ // Enforce max stack limit (remove oldest)
122
+ while (this.toastStack.length > maxToasts) {
123
+ const oldest = this.toastStack[0];
124
+ if (oldest && oldest.toastId) this.removeFromToastStack(oldest.toastId);
125
}
126
127
// Set auto-dismiss timer
@@ -122,7 +131,7 @@ const model = {
131
},
132
133
// NEW: Remove toast from stack
125
- removeFromToastStack(toastId) {
134
+ removeFromToastStack(toastId, removedByUser = false) {
135
const index = this.toastStack.findIndex((t) => t.toastId === toastId);
136
if (index >= 0) {
137
const toast = this.toastStack[index];
@@ -130,14 +139,30 @@ const model = {
139
clearTimeout(toast.autoRemoveTimer);
140
}
141
this.toastStack.splice(index, 1);
142
+
143
+ // execute after toast removed callback
144
+ this.afterToastRemoved(toast, removedByUser);
145
+ }
146
+ },
147
+
148
+ // called by UI
149
+ dismissToast(toastId) {
150
+ this.removeFromToastStack(toastId, true);
151
+ },
152
+
153
+ async afterToastRemoved(toast, removedByUser = false) {
154
+ // if the toast is closed by the user OR timed out with normal priority, mark it as read
155
+ if (removedByUser || toast.priority <= NotificationPriority.NORMAL) {
156
+ this.markAsRead(toast.id);
157
}
158
},
159
160
// NEW: Clear entire toast stack
137
- clearToastStack() {
161
+ clearToastStack(withCallback = true, removedByUser = false) {
162
this.toastStack.forEach((toast) => {
163
if (toast.autoRemoveTimer) {
164
clearTimeout(toast.autoRemoveTimer);
165
+ if (withCallback) this.afterToastRemoved(toast, removedByUser);
166
}
167
});
168
this.toastStack = [];
@@ -179,12 +204,21 @@ const model = {
204
// Add new notification at the beginning (most recent first)
205
this.notifications.unshift(notification);
206
}
207
+
208
+ // Limit notifications to prevent memory issues (keep most recent)
209
+ if (this.notifications.length > maxNotifications) {
210
+ this.notifications = this.notifications.slice(0, maxNotifications);
211
+ }
212
},
213
214
// Update unread count
215
updateUnreadCount() {
216
const unread = this.notifications.filter((n) => !n.read).length;
217
+ const unreadPrio = this.notifications.filter(
218
+ (n) => !n.read && n.priority > NotificationPriority.NORMAL
219
+ ).length;
220
if (this.unreadCount !== unread) this.unreadCount = unread;
221
+ if (this.unreadPrioCount !== unreadPrio) this.unreadPrioCount = unreadPrio;
222
},
223
224
// Mark notification as read
@@ -220,7 +254,7 @@ const model = {
254
this.updateUnreadCount();
255
256
// Clear toast stack when marking all as read
223
- this.clearToastStack();
257
+ this.clearToastStack(false);
258
259
// Sync with backend (non-blocking)
260
try {
@@ -233,12 +267,19 @@ const model = {
267
},
268
269
// Clear all notifications
236
- async clearAll() {
270
+ async clearAll(syncBackend = true) {
271
this.notifications = [];
272
this.unreadCount = 0;
239
- this.clearToastStack(); // Also clear toast stack
273
+ this.clearToastStack(false); // Also clear toast stack
274
+ this.clearBackendNotifications();
275
+ },
276
241
- // Note: We don't sync clear with backend as notifications are stored in memory only
277
+ async clearBackendNotifications() {
278
+ try {
279
+ await API.callJsonApi("notifications_clear", null);
280
+ } catch (error) {
281
+ console.error("Failed to clear notifications:", error);
282
+ }
283
},
284
285
// Get notifications by type
@@ -290,14 +331,15 @@ const model = {
331
const date = new Date(timestamp);
332
const now = new Date();
333
const diffMs = now - date;
293
- const diffMins = Math.floor(diffMs / 60000);
294
- const diffHours = Math.floor(diffMs / 3600000);
295
- const diffDays = Math.floor(diffMs / 86400000);
334
+ const diffMins = diffMs / 60000;
335
+ const diffHours = diffMs / 3600000;
336
+ const diffDays = diffMs / 86400000;
337
297
- if (diffMins < 1) return "Just now";
298
- if (diffMins < 60) return `${diffMins}m ago`;
299
- if (diffHours < 24) return `${diffHours}h ago`;
300
- if (diffDays < 7) return `${diffDays}d ago`;
338
+ if (diffMins < 0.15) return "Just now";
339
+ else if (diffMins < 1) return "Less than a minute ago";
340
+ else if (diffMins < 60) return `${Math.round(diffMins)}m ago`;
341
+ else if (diffHours < 24) return `${Math.round(diffHours)}h ago`;
342
+ else if (diffDays < 7) return `${Math.round(diffDays)}d ago`;
343
344
return date.toLocaleDateString();
345
},
@@ -341,7 +383,8 @@ const model = {
383
title = "",
384
detail = "",
385
display_time = 3,
344
- group = ""
386
+ group = "",
387
+ priority = defaultPriority
388
) {
389
try {
390
const response = await globalThis.sendJsonData("/notification_create", {
@@ -351,6 +394,7 @@ const model = {
394
detail: detail,
395
display_time: display_time,
396
group: group,
397
+ priority: priority,
398
});
399
400
if (response.success) {
@@ -366,14 +410,22 @@ const model = {
410
},
411
412
// Convenience methods for different notification types
369
- async info(message, title = "", detail = "", display_time = 3, group = "") {
413
+ async info(
414
+ message,
415
+ title = "",
416
+ detail = "",
417
+ display_time = 3,
418
+ group = "",
419
+ priority = defaultPriority
420
+ ) {
421
return await this.createNotification(
371
- "info",
422
+ NotificationType.INFO,
423
message,
424
title,
425
detail,
426
display_time,
376
- group
427
+ group,
428
+ priority
429
);
430
},
431
@@ -382,15 +434,17 @@ const model = {
434
title = "",
435
detail = "",
436
display_time = 3,
385
- group = ""
437
+ group = "",
438
+ priority = defaultPriority
439
) {
440
return await this.createNotification(
388
- "success",
441
+ NotificationType.SUCCESS,
442
message,
443
title,
444
detail,
445
display_time,
393
- group
446
+ group,
447
+ priority
448
);
449
},
450
@@ -399,26 +453,36 @@ const model = {
453
title = "",
454
detail = "",
455
display_time = 3,
402
- group = ""
456
+ group = "",
457
+ priority = defaultPriority
458
) {
459
return await this.createNotification(
405
- "warning",
460
+ NotificationType.WARNING,
461
message,
462
title,
463
detail,
464
display_time,
410
- group
465
+ group,
466
+ priority
467
);
468
},
469
414
- async error(message, title = "", detail = "", display_time = 3, group = "") {
470
+ async error(
471
+ message,
472
+ title = "",
473
+ detail = "",
474
+ display_time = 3,
475
+ group = "",
476
+ priority = defaultPriority
477
+ ) {
478
return await this.createNotification(
416
- "error",
479
+ NotificationType.ERROR,
480
message,
481
title,
482
detail,
483
display_time,
421
- group
484
+ group,
485
+ priority
486
);
487
},
488
@@ -427,26 +491,28 @@ const model = {
491
title = "",
492
detail = "",
493
display_time = 3,
430
- group = ""
494
+ group = "",
495
+ priority = defaultPriority
496
) {
497
return await this.createNotification(
433
- "progress",
498
+ NotificationType.PROGRESS,
499
message,
500
title,
501
detail,
502
display_time,
438
- group
503
+ group,
504
+ priority
505
);
506
},
507
508
// Enhanced: Open modal and clear toast stack
509
async openModal() {
444
- // Import the standard modal system
445
- const { openModal } = await import("/js/modals.js");
446
- await openModal("notifications/notification-modal.html");
447
-
510
// Clear toast stack when modal opens
449
- this.clearToastStack();
511
+ this.clearToastStack(false);
512
+ // open modal
513
+ await openModal("notifications/notification-modal.html");
514
+ // mark all as read when modal closes
515
+ this.markAllAsRead();
516
},
517
518
// Legacy method for backward compatibility
@@ -479,7 +545,8 @@ const model = {
545
message,
546
title = "",
547
display_time = 5,
482
- group = ""
548
+ group = "",
549
+ priority = defaultPriority
550
) {
551
const timestamp = new Date().toISOString();
552
const notification = {
@@ -493,10 +560,14 @@ const model = {
560
read: false,
561
frontend: true, // Mark as frontend-only
562
group: group,
563
+ priority: priority,
564
};
565
566
+ //adjust data before using
567
+ this.adjustNotificationData(notification);
568
+
569
// If notification has a group, remove any existing toasts with the same group
499
- if (group && group.trim() !== "") {
570
+ if (group && String(group).trim() !== "") {
571
const existingToastIndex = this.toastStack.findIndex(
572
(t) => t.group === group
573
);
@@ -543,7 +614,8 @@ const model = {
614
message,
615
title = "",
616
display_time = 5,
546
- group = ""
617
+ group = "",
618
+ priority = defaultPriority
619
) {
620
// Try to send to backend first if connected
621
if (this.isConnected()) {
@@ -554,7 +626,8 @@ const model = {
626
title,
627
"",
628
display_time,
557
- group
629
+ group,
630
+ priority
631
);
632
if (notificationId) {
633
// Backend handled it, notification will arrive via polling
@@ -572,7 +645,14 @@ const model = {
645
}
646
647
// Fallback to frontend-only toast
575
- return this.addFrontendToastOnly(type, message, title, display_time, group);
648
+ return this.addFrontendToastOnly(
649
+ type,
650
+ message,
651
+ title,
652
+ display_time,
653
+ group,
654
+ priority
655
+ );
656
},
657
658
// NEW: Convenience methods for frontend notifications (updated to use new backend-first logic)
@@ -580,14 +660,16 @@ const model = {
660
message,
661
title = "Connection Error",
662
display_time = 8,
583
- group = ""
663
+ group = "",
664
+ priority = defaultPriority
665
) {
666
return await this.addFrontendToast(
586
- "error",
667
+ NotificationType.ERROR,
668
message,
669
title,
670
display_time,
590
- group
671
+ group,
672
+ priority
673
);
674
},
675
@@ -595,24 +677,33 @@ const model = {
677
message,
678
title = "Warning",
679
display_time = 5,
598
- group = ""
680
+ group = "",
681
+ priority = defaultPriority
682
) {
683
return await this.addFrontendToast(
601
- "warning",
684
+ NotificationType.WARNING,
685
message,
686
title,
687
display_time,
605
- group
688
+ group,
689
+ priority
690
);
691
},
692
609
- async frontendInfo(message, title = "Info", display_time = 3, group = "") {
693
+ async frontendInfo(
694
+ message,
695
+ title = "Info",
696
+ display_time = 3,
697
+ group = "",
698
+ priority = defaultPriority
699
+ ) {
700
return await this.addFrontendToast(
611
- "info",
701
+ NotificationType.INFO,
702
message,
703
title,
704
display_time,
615
- group
705
+ group,
706
+ priority
707
);
708
},
709
@@ -620,14 +711,16 @@ const model = {
711
message,
712
title = "Success",
713
display_time = 3,
623
- group = ""
714
+ group = "",
715
+ priority = defaultPriority
716
) {
717
return await this.addFrontendToast(
626
- "success",
718
+ NotificationType.SUCCESS,
719
message,
720
title,
721
display_time,
630
- group
722
+ group,
723
+ priority
724
);
725
},
726
};
@@ -636,7 +729,6 @@ const model = {
729
const store = createStore("notificationStore", model);
730
export { store };
731
639
-
732
// add toasts to global for backward compatibility with older scripts
733
globalThis.toastFrontendInfo = store.frontendInfo.bind(store);
734
globalThis.toastFrontendSuccess = store.frontendSuccess.bind(store);
webui/components/notifications/notification-toast-stack.html
+2
-2
@@ -40,7 +40,7 @@
40
41
<!-- Toast Dismiss -->
42
<button class="toast-dismiss"
43
- @click.stop="$store.notificationStore.removeFromToastStack(toast.toastId)"
43
+ @click.stop="$store.notificationStore.dismissToast(toast.toastId)"
44
title="Dismiss">
45
<span class="material-symbols-outlined">close</span>
46
</button>
@@ -62,7 +62,7 @@
62
gap: 8px;
63
pointer-events: none;
64
max-width: 400px;
65
- width: calc(100% - 10px);
65
+ right: 5px; /* Anchor to the right */
66
align-items: flex-end;
67
}
68
webui/index.js
+13
-2
@@ -636,7 +636,7 @@ globalThis.killChat = async function (id) {
636
637
updateAfterScroll();
638
639
- toast("Chat deleted successfully", "success");
639
+ justToast("Chat deleted successfully", "success", 1000, "chat-removal");
640
} catch (e) {
641
console.error("Error deleting chat:", e);
642
globalThis.toastFetchError("Error deleting chat", e);
@@ -990,9 +990,20 @@ function removeClassFromElement(element, className) {
990
element.classList.remove(className);
991
}
992
993
+function justToast(text, type = "info", timeout = 5000, group = "") {
994
+ notificationStore.addFrontendToastOnly(
995
+ type,
996
+ text,
997
+ "",
998
+ timeout / 1000,
999
+ group
1000
+ )
1001
+}
1002
+
1003
+
1004
function toast(text, type = "info", timeout = 5000) {
1005
// Convert timeout from milliseconds to seconds for new notification system
995
- const display_time = Math.max(timeout / 1000, 3); // Minimum 3 seconds
1006
+ const display_time = Math.max(timeout / 1000, 1); // Minimum 1 second
1007
1008
// Use new frontend notification system based on type
1009
switch (type.toLowerCase()) {