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()) {