feat: notifications backend system and frontend display
Rafael Uzarowski committed
Jul 1, 2025 at 16:14 UTC
e476b4bfceca16ef7d8ae29b0d8af050f4f53f8a
19 files changed
+1581
-9
agent.py
+8
@@ -36,6 +36,7 @@ class AgentContext:
36
37
_contexts: dict[str, "AgentContext"] = {}
38
_counter: int = 0
39
+ _notification_manager = None
40
41
def __init__(
42
self,
@@ -85,6 +86,13 @@ class AgentContext:
86
def all():
87
return list(AgentContext._contexts.values())
88
89
+ @classmethod
90
+ def get_notification_manager(cls):
91
+ if cls._notification_manager is None:
92
+ from python.helpers.notification import NotificationManager
93
+ cls._notification_manager = NotificationManager()
94
+ return cls._notification_manager
95
+
96
@staticmethod
97
def remove(id: str):
98
context = AgentContext._contexts.pop(id, None)
prompts/default/agent.system.tool.notify_user.md
new
+43
@@ -0,0 +1,43 @@
1
+### notify_user:
2
+This tool can be used to notify the user of a message independent of the current task.
3
+
4
+!!! This is a universal notification tool
5
+!!! Supported notification types: info, success, warning, error, progress
6
+
7
+#### Arguments:
8
+ * "message" (string) : The message to be displayed to the user.
9
+ * "title" (Optional, string) : The title of the notification.
10
+ * "detail" (Optional, string) : The detail of the notification. May contain html tags.
11
+ * "type" (Optional, string) : The type of the notification. Can be "info", "success", "warning", "error", "progress".
12
+
13
+#### Usage examples:
14
+##### 1: Success notification
15
+```json
16
+{
17
+ "thoughts": [
18
+ "...",
19
+ ],
20
+ "tool_name": "notify_user",
21
+ "tool_args": {
22
+ "message": "Important notification: task xyz is completed succesfully",
23
+ "title": "Task Completed",
24
+ "detail": "This is a test notification detail with <a href='https://www.google.com'>link</a>",
25
+ "type": "success"
26
+ }
27
+}
28
+```
29
+##### 2: Error notification
30
+```json
31
+{
32
+ "thoughts": [
33
+ "...",
34
+ ],
35
+ "tool_name": "notify_user",
36
+ "tool_args": {
37
+ "message": "Important notification: task xyz is failed",
38
+ "title": "Task Failed",
39
+ "detail": "This is a test notification detail with <a href='https://www.google.com'>link</a> and <img src='https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png'>",
40
+ "type": "error"
41
+ }
42
+}
43
+```
prompts/default/agent.system.tools.md
+2
@@ -19,3 +19,5 @@
19
{{ include './agent.system.tool.scheduler.md' }}
20
21
{{ include './agent.system.tool.document_query.md' }}
22
+
23
+{{ include './agent.system.tool.notify_user.md' }}
prompts/default/fw.notify_user.notification_sent.md
new
+1
@@ -0,0 +1 @@
1
+The notification has been sent to the user.
python/api/notification_create.py
new
+63
@@ -0,0 +1,63 @@
1
+from python.helpers.api import ApiHandler
2
+from flask import Request, Response
3
+from python.helpers.notification import AgentNotification, NotificationType
4
+
5
+
6
+class NotificationCreate(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
+ # Extract notification data
13
+ notification_type = input.get("type", "info")
14
+ message = input.get("message", "")
15
+ title = input.get("title", "")
16
+ detail = input.get("detail", "")
17
+ display_time = input.get("display_time", 3) # Default to 3 seconds
18
+
19
+ # Validate required fields
20
+ if not message:
21
+ return {"success": False, "error": "Message is required"}
22
+
23
+ # Validate display_time
24
+ try:
25
+ display_time = int(display_time)
26
+ if display_time <= 0:
27
+ display_time = 3 # Reset to default if invalid
28
+ except (ValueError, TypeError):
29
+ display_time = 3 # Reset to default if not convertible to int
30
+
31
+ # Validate notification type
32
+ try:
33
+ if isinstance(notification_type, str):
34
+ notification_type = NotificationType(notification_type.lower())
35
+ except ValueError:
36
+ return {"success": False, "error": f"Invalid notification type: {notification_type}"}
37
+
38
+ # Create notification using the appropriate helper method
39
+ try:
40
+ if notification_type == NotificationType.INFO:
41
+ notification = AgentNotification.info(message, title, detail, display_time)
42
+ elif notification_type == NotificationType.SUCCESS:
43
+ notification = AgentNotification.success(message, title, detail, display_time)
44
+ elif notification_type == NotificationType.WARNING:
45
+ notification = AgentNotification.warning(message, title, detail, display_time)
46
+ elif notification_type == NotificationType.ERROR:
47
+ notification = AgentNotification.error(message, title, detail, display_time)
48
+ elif notification_type == NotificationType.PROGRESS:
49
+ notification = AgentNotification.progress(message, title, detail, display_time)
50
+ else:
51
+ notification = AgentNotification.info(message, title, detail, display_time)
52
+
53
+ return {
54
+ "success": True,
55
+ "notification_id": notification.id,
56
+ "message": "Notification created successfully"
57
+ }
58
+
59
+ except Exception as e:
60
+ return {
61
+ "success": False,
62
+ "error": f"Failed to create notification: {str(e)}"
63
+ }
python/api/notifications_history.py
new
+20
@@ -0,0 +1,20 @@
1
+from python.helpers.api import ApiHandler
2
+from flask import Request, Response
3
+from agent import AgentContext
4
+
5
+
6
+class NotificationsHistory(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
+ # Return all notifications for history modal
16
+ return {
17
+ "notifications": [n.output() for n in notification_manager.notifications],
18
+ "guid": notification_manager.guid,
19
+ "count": len(notification_manager.notifications),
20
+ }
python/api/notifications_mark_read.py
new
+38
@@ -0,0 +1,38 @@
1
+from python.helpers.api import ApiHandler
2
+from flask import Request, Response
3
+from agent import AgentContext
4
+
5
+
6
+class NotificationsMarkRead(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
+ notification_ids = input.get("notification_ids", [])
13
+ mark_all = input.get("mark_all", False)
14
+
15
+ notification_manager = AgentContext.get_notification_manager()
16
+
17
+ if mark_all:
18
+ notification_manager.mark_all_read()
19
+ return {"success": True, "message": "All notifications marked as read"}
20
+
21
+ if not notification_ids:
22
+ return {"success": False, "error": "No notification IDs provided"}
23
+
24
+ # Mark specific notifications as read
25
+ marked_count = 0
26
+ for notification_id in notification_ids:
27
+ # Find notification by ID and mark as read
28
+ for notification in notification_manager.notifications:
29
+ if notification.id == notification_id and not notification.read:
30
+ notification.mark_read()
31
+ marked_count += 1
32
+ break
33
+
34
+ return {
35
+ "success": True,
36
+ "marked_count": marked_count,
37
+ "message": f"Marked {marked_count} notifications as read"
38
+ }
python/api/poll.py
+9
-4
@@ -1,11 +1,8 @@
1
-import time
2
-from datetime import datetime
1
from python.helpers.api import ApiHandler
2
from flask import Request, Response
3
4
from agent import AgentContext
5
8
-from python.helpers import persist_chat
6
from python.helpers.task_scheduler import TaskScheduler
7
from python.helpers.localization import Localization
8
from python.helpers.dotenv import get_dotenv_value
@@ -16,6 +13,7 @@ class Poll(ApiHandler):
13
async def process(self, input: dict, request: Request) -> dict | Response:
14
ctxid = input.get("context", None)
15
from_no = input.get("log_from", 0)
16
+ notifications_from = input.get("notifications_from", 0)
17
18
# Get timezone from input (default to dotenv default or UTC if not provided)
19
timezone = input.get("timezone", get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC"))
@@ -26,6 +24,10 @@ class Poll(ApiHandler):
24
25
logs = context.log.output(start=from_no)
26
27
+ # Get notifications from global notification manager
28
+ notification_manager = AgentContext.get_notification_manager()
29
+ notifications = notification_manager.output(start=notifications_from)
30
+
31
# loop AgentContext._contexts
32
33
# Get a task scheduler instance
@@ -65,7 +67,7 @@ class Poll(ApiHandler):
67
# Add task details to context_data with the same field names
68
# as used in scheduler endpoints to maintain UI compatibility
69
context_data.update({
68
- "task_name": task_details.get("name"), # name is for context, task_name for the task name
70
+ "task_name": task_details.get("name"), # name is for context, task_name for the task name
71
"uuid": task_details.get("uuid"),
72
"state": task_details.get("state"),
73
"type": task_details.get("type"),
@@ -105,4 +107,7 @@ class Poll(ApiHandler):
107
"log_progress": context.log.progress,
108
"log_progress_active": context.log.progress_active,
109
"paused": context.paused,
110
+ "notifications": notifications,
111
+ "notifications_guid": notification_manager.guid,
112
+ "notifications_version": len(notification_manager.updates),
113
}
python/helpers/notification.py
new
+174
@@ -0,0 +1,174 @@
1
+from dataclasses import dataclass
2
+import uuid
3
+from datetime import datetime, timezone, timedelta
4
+from enum import Enum
5
+
6
+
7
+class NotificationType(Enum):
8
+ INFO = "info"
9
+ SUCCESS = "success"
10
+ WARNING = "warning"
11
+ ERROR = "error"
12
+ PROGRESS = "progress"
13
+
14
+
15
+@dataclass
16
+class NotificationItem:
17
+ manager: "NotificationManager"
18
+ no: int
19
+ type: NotificationType
20
+ title: str
21
+ message: str
22
+ detail: str # HTML content for expandable details
23
+ timestamp: datetime
24
+ display_time: int = 3 # Display duration in seconds, default 3 seconds
25
+ read: bool = False
26
+ id: str = ""
27
+
28
+ def __post_init__(self):
29
+ if not self.id:
30
+ self.id = str(uuid.uuid4())
31
+ # Ensure type is always NotificationType
32
+ if isinstance(self.type, str):
33
+ self.type = NotificationType(self.type)
34
+
35
+ def mark_read(self):
36
+ self.read = True
37
+ self.manager._update_item(self.no, read=True)
38
+
39
+ def output(self):
40
+ return {
41
+ "no": self.no,
42
+ "id": self.id,
43
+ "type": self.type.value,
44
+ "title": self.title,
45
+ "message": self.message,
46
+ "detail": self.detail,
47
+ "timestamp": self.timestamp.isoformat(),
48
+ "display_time": self.display_time,
49
+ "read": self.read,
50
+ }
51
+
52
+
53
+class NotificationManager:
54
+ def __init__(self, max_notifications: int = 100):
55
+ self.guid: str = str(uuid.uuid4())
56
+ self.updates: list[int] = []
57
+ self.notifications: list[NotificationItem] = []
58
+ self.max_notifications = max_notifications
59
+
60
+ def add_notification(
61
+ self,
62
+ type: NotificationType,
63
+ message: str,
64
+ title: str = "",
65
+ detail: str = "",
66
+ display_time: int = 3,
67
+ ) -> NotificationItem:
68
+ # Create notification item
69
+ item = NotificationItem(
70
+ manager=self,
71
+ no=len(self.notifications),
72
+ type=type,
73
+ title=title,
74
+ message=message,
75
+ detail=detail,
76
+ timestamp=datetime.now(timezone.utc),
77
+ display_time=display_time,
78
+ )
79
+
80
+ # Add to notifications
81
+ self.notifications.append(item)
82
+ self.updates.append(item.no)
83
+
84
+ # Enforce limit
85
+ self._enforce_limit()
86
+
87
+ return item
88
+
89
+ def _enforce_limit(self):
90
+ if len(self.notifications) > self.max_notifications:
91
+ # Remove oldest notifications
92
+ to_remove = len(self.notifications) - self.max_notifications
93
+ self.notifications = self.notifications[to_remove:]
94
+ # Adjust notification numbers
95
+ for i, notification in enumerate(self.notifications):
96
+ notification.no = i
97
+ # Adjust updates list
98
+ self.updates = [no - to_remove for no in self.updates if no >= to_remove]
99
+
100
+ def get_recent_notifications(self, seconds: int = 30) -> list[NotificationItem]:
101
+ cutoff = datetime.now(timezone.utc) - timedelta(seconds=seconds)
102
+ return [n for n in self.notifications if n.timestamp >= cutoff]
103
+
104
+ def output(self, start: int | None = None, end: int | None = None) -> list[dict]:
105
+ if start is None:
106
+ start = 0
107
+ if end is None:
108
+ end = len(self.updates)
109
+
110
+ out = []
111
+ seen = set()
112
+ for update in self.updates[start:end]:
113
+ if update not in seen and update < len(self.notifications):
114
+ out.append(self.notifications[update].output())
115
+ seen.add(update)
116
+
117
+ return out
118
+
119
+ def _update_item(self, no: int, **kwargs):
120
+ if no < len(self.notifications):
121
+ item = self.notifications[no]
122
+ for key, value in kwargs.items():
123
+ if hasattr(item, key):
124
+ setattr(item, key, value)
125
+ self.updates.append(no)
126
+
127
+ def mark_all_read(self):
128
+ for notification in self.notifications:
129
+ notification.read = True
130
+
131
+ def clear_all(self):
132
+ self.notifications = []
133
+ self.updates = []
134
+ self.guid = str(uuid.uuid4())
135
+
136
+ def get_notifications_by_type(self, type: NotificationType) -> list[NotificationItem]:
137
+ return [n for n in self.notifications if n.type == type]
138
+
139
+
140
+class AgentNotification:
141
+ @staticmethod
142
+ def info(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
143
+ from agent import AgentContext
144
+ return AgentContext.get_notification_manager().add_notification(
145
+ NotificationType.INFO, message, title, detail, display_time
146
+ )
147
+
148
+ @staticmethod
149
+ def success(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
150
+ from agent import AgentContext
151
+ return AgentContext.get_notification_manager().add_notification(
152
+ NotificationType.SUCCESS, message, title, detail, display_time
153
+ )
154
+
155
+ @staticmethod
156
+ def warning(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
157
+ from agent import AgentContext
158
+ return AgentContext.get_notification_manager().add_notification(
159
+ NotificationType.WARNING, message, title, detail, display_time
160
+ )
161
+
162
+ @staticmethod
163
+ def error(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
164
+ from agent import AgentContext
165
+ return AgentContext.get_notification_manager().add_notification(
166
+ NotificationType.ERROR, message, title, detail, display_time
167
+ )
168
+
169
+ @staticmethod
170
+ def progress(message: str, title: str = "", detail: str = "", display_time: int = 3) -> NotificationItem:
171
+ from agent import AgentContext
172
+ return AgentContext.get_notification_manager().add_notification(
173
+ NotificationType.PROGRESS, message, title, detail, display_time
174
+ )
python/tools/notify_user.py
new
+26
@@ -0,0 +1,26 @@
1
+from python.helpers.tool import Tool, Response
2
+from agent import AgentContext
3
+
4
+
5
+class NotifyUserTool(Tool):
6
+
7
+ async def execute(self, **kwargs):
8
+
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")
13
+
14
+ if notification_type not in ["info", "success", "warning", "error", "progress"]:
15
+ return Response(message=f"Invalid notification type: {notification_type}", break_loop=False)
16
+
17
+ if not message:
18
+ return Response(message="Message is required", break_loop=False)
19
+
20
+ AgentContext.get_notification_manager().add_notification(
21
+ message=message,
22
+ title=title,
23
+ detail=detail,
24
+ type=notification_type,
25
+ )
26
+ return Response(message=self.agent.read_prompt("fw.notify_user.notification_sent.md"), break_loop=False)
webui/components/notifications/notification-icons.html
new
+34
@@ -0,0 +1,34 @@
1
+<html>
2
+<head>
3
+ <script type="module">
4
+ import { store } from "/js/notificationStore.js";
5
+ console.log('Notification component script loaded');
6
+ </script>
7
+</head>
8
+<body>
9
+ <div x-data>
10
+ <!-- Notification Toggle Button (always visible and clickable) -->
11
+ <div class="notification-toggle"
12
+ :class="{
13
+ 'has-unread': $store.notificationStore.unreadCount > 0,
14
+ 'has-notifications': $store.notificationStore.notifications.length > 0
15
+ }"
16
+ @click="$store.notificationStore.openModal()"
17
+ title="View Notifications">
18
+ <div class="notification-icon">
19
+ 🔔
20
+ </div>
21
+ <span x-show="$store.notificationStore.unreadCount > 0"
22
+ class="notification-badge"
23
+ x-text="$store.notificationStore.unreadCount"></span>
24
+ </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
+ </div>
33
+</body>
34
+</html>
webui/components/notifications/notification-modal.html
new
+359
@@ -0,0 +1,359 @@
1
+<html>
2
+<head>
3
+ <title>Notifications</title>
4
+ <script type="module">
5
+ import { store } from "/js/notificationStore.js";
6
+
7
+ // Ensure notification store is available globally for the modal
8
+ if (!window.notificationStore) {
9
+ window.notificationStore = store;
10
+ }
11
+ </script>
12
+</head>
13
+<body>
14
+ <div x-data="notificationModalComponent()">
15
+ <!-- Modal Header Actions -->
16
+ <div class="modal-subheader">
17
+ <div class="notification-header-actions">
18
+ <button class="notification-action"
19
+ @click="$store.notificationStore.clearAll()"
20
+ :disabled="$store.notificationStore.getDisplayNotifications().length === 0"
21
+ title="Clear All">
22
+ 🗑️ Clear All
23
+ </button>
24
+ </div>
25
+ </div>
26
+
27
+ <!-- Notifications List -->
28
+ <div class="notification-list" x-show="$store.notificationStore.getDisplayNotifications().length > 0">
29
+ <template x-for="notification in $store.notificationStore.getDisplayNotifications()" :key="notification.id">
30
+ <div class="notification-item"
31
+ x-data="{ expanded: false }"
32
+ :class="$store.notificationStore.getNotificationItemClass(notification)"
33
+ @click="$store.notificationStore.markAsRead(notification.id)">
34
+
35
+ <div class="notification-icon"
36
+ x-html="$store.notificationStore.getNotificationIcon(notification.type)">
37
+ </div>
38
+
39
+ <div class="notification-content">
40
+ <div class="notification-title"
41
+ x-show="notification.title"
42
+ x-text="notification.title">
43
+ </div>
44
+ <div class="notification-message"
45
+ x-text="notification.message">
46
+ </div>
47
+ <div class="notification-timestamp"
48
+ x-text="$store.notificationStore.formatTimestamp(notification.timestamp)">
49
+ </div>
50
+
51
+ <!-- Expand Toggle Button (as last row element) -->
52
+ <button class="notification-expand-toggle"
53
+ x-show="notification.detail"
54
+ @click.stop="expanded = !expanded"
55
+ :title="expanded ? 'Collapse Details' : 'Expand Details'">
56
+ <span x-show="!expanded">▶ Show Details</span>
57
+ <span x-show="expanded">▼ Hide Details</span>
58
+ </button>
59
+
60
+ <!-- Expandable Detail Content -->
61
+ <div class="notification-detail"
62
+ x-show="expanded && notification.detail"
63
+ x-transition:enter="transition ease-out duration-200"
64
+ x-transition:enter-start="opacity-0 max-h-0"
65
+ x-transition:enter-end="opacity-100 max-h-96"
66
+ x-transition:leave="transition ease-in duration-200"
67
+ x-transition:leave-start="opacity-100 max-h-96"
68
+ x-transition:leave-end="opacity-0 max-h-0">
69
+ <div class="notification-detail-content" x-html="notification.detail"></div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </template>
74
+ </div>
75
+
76
+ <!-- Empty State -->
77
+ <div class="notification-empty" x-show="$store.notificationStore.getDisplayNotifications().length === 0">
78
+ <div class="notification-empty-icon">🔔</div>
79
+ <p>No notifications to display</p>
80
+ <p style="font-size: 0.8rem; opacity: 0.7; margin-top: 0.5rem;"
81
+ x-show="$store.notificationStore.notifications.length > 0">
82
+ All notifications have been read and are older than 5 minutes
83
+ </p>
84
+ </div>
85
+ </div>
86
+
87
+ <style>
88
+ /* Modal-specific styles that override the standard modal */
89
+ .modal-container.notification-modal {
90
+ width: 90%;
91
+ max-width: 600px;
92
+ max-height: 80vh;
93
+ }
94
+
95
+ .notification-header-actions {
96
+ display: flex;
97
+ gap: 0.5rem;
98
+ align-items: center;
99
+ justify-content: flex-end;
100
+ width: 100%;
101
+ }
102
+
103
+ .notification-action {
104
+ padding: 0.5rem 0.75rem;
105
+ background: rgba(255, 255, 255, 0.1);
106
+ border: 1px solid rgba(255, 255, 255, 0.2);
107
+ border-radius: 4px;
108
+ color: var(--color-text);
109
+ cursor: pointer;
110
+ transition: all 0.2s ease;
111
+ font-size: 0.85rem;
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 0.25rem;
115
+ }
116
+
117
+ .notification-action:hover {
118
+ background: rgba(255, 255, 255, 0.15);
119
+ border-color: rgba(255, 255, 255, 0.3);
120
+ }
121
+
122
+ .notification-action:disabled {
123
+ opacity: 0.4;
124
+ cursor: not-allowed;
125
+ }
126
+
127
+ .notification-action:disabled:hover {
128
+ background: rgba(255, 255, 255, 0.1);
129
+ border-color: rgba(255, 255, 255, 0.2);
130
+ }
131
+
132
+ /* Notification List */
133
+ .notification-list {
134
+ max-height: 60vh;
135
+ overflow-y: auto;
136
+ padding: 0.5rem 0;
137
+ }
138
+
139
+ .notification-list::-webkit-scrollbar {
140
+ width: 6px;
141
+ }
142
+
143
+ .notification-list::-webkit-scrollbar-track {
144
+ background: transparent;
145
+ }
146
+
147
+ .notification-list::-webkit-scrollbar-thumb {
148
+ background-color: rgba(155, 155, 155, 0.3);
149
+ border-radius: 6px;
150
+ }
151
+
152
+ .notification-list::-webkit-scrollbar-thumb:hover {
153
+ background-color: rgba(155, 155, 155, 0.5);
154
+ }
155
+
156
+ /* Include all the notification item styles from the CSS file */
157
+ .notification-item {
158
+ display: flex;
159
+ align-items: flex-start;
160
+ gap: 0.75rem;
161
+ padding: 1rem;
162
+ margin-bottom: 0.5rem;
163
+ background: rgba(0, 0, 0, 0.1);
164
+ border: 1px solid rgba(255, 255, 255, 0.1);
165
+ border-radius: 8px;
166
+ cursor: pointer;
167
+ transition: all 0.2s ease;
168
+ position: relative;
169
+ }
170
+
171
+ .notification-item:hover {
172
+ background: rgba(0, 0, 0, 0.15);
173
+ border-color: rgba(255, 255, 255, 0.2);
174
+ }
175
+
176
+ .notification-item:last-child {
177
+ margin-bottom: 0;
178
+ }
179
+
180
+ .notification-item.unread {
181
+ border-left: 4px solid #2196F3;
182
+ background: rgba(33, 150, 243, 0.05);
183
+ }
184
+
185
+ .notification-item.read {
186
+ opacity: 0.7;
187
+ }
188
+
189
+ .notification-item.read .notification-title {
190
+ color: var(--color-text-muted);
191
+ }
192
+
193
+ .notification-item.read .notification-message {
194
+ color: var(--color-text-muted);
195
+ }
196
+
197
+ /* Notification Icon */
198
+ .notification-icon {
199
+ font-size: 1.1rem;
200
+ line-height: 1;
201
+ opacity: 0.8;
202
+ min-width: 20px;
203
+ text-align: center;
204
+ margin-top: 0.1rem;
205
+ }
206
+
207
+ .notification-info .notification-icon {
208
+ color: #2196F3;
209
+ }
210
+
211
+ .notification-success .notification-icon {
212
+ color: #4CAF50;
213
+ }
214
+
215
+ .notification-warning .notification-icon {
216
+ color: #FF9800;
217
+ }
218
+
219
+ .notification-error .notification-icon {
220
+ color: #F44336;
221
+ }
222
+
223
+ .notification-progress .notification-icon {
224
+ color: #9C27B0;
225
+ animation: spin 2s linear infinite;
226
+ }
227
+
228
+ /* Notification Content */
229
+ .notification-content {
230
+ flex: 1;
231
+ min-width: 0;
232
+ }
233
+
234
+ .notification-title {
235
+ font-weight: 600;
236
+ font-size: 0.9rem;
237
+ color: var(--color-primary);
238
+ margin-bottom: 0.25rem;
239
+ line-height: 1.3;
240
+ }
241
+
242
+ .notification-message {
243
+ font-size: 0.85rem;
244
+ color: var(--color-text);
245
+ line-height: 1.4;
246
+ margin-bottom: 0.5rem;
247
+ }
248
+
249
+ .notification-expand-toggle {
250
+ background: transparent;
251
+ border: none;
252
+ color: var(--color-text);
253
+ cursor: pointer;
254
+ padding: 0.25rem;
255
+ border-radius: 4px;
256
+ transition: all 0.2s ease;
257
+ font-size: 0.8rem;
258
+ opacity: 0.7;
259
+ display: flex;
260
+ align-items: center;
261
+ gap: 0.25rem;
262
+ }
263
+
264
+ .notification-expand-toggle:hover {
265
+ opacity: 1;
266
+ background: rgba(255, 255, 255, 0.1);
267
+ }
268
+
269
+ .notification-timestamp {
270
+ font-size: 0.75rem;
271
+ color: var(--color-text);
272
+ opacity: 0.6;
273
+ margin-top: 0.5rem;
274
+ }
275
+
276
+ /* Notification Detail */
277
+ .notification-detail {
278
+ margin-top: 0.75rem;
279
+ padding: 0.75rem;
280
+ background: rgba(0, 0, 0, 0.1);
281
+ border-radius: 6px;
282
+ border-left: 3px solid rgba(255, 255, 255, 0.2);
283
+ overflow: hidden;
284
+ transition: all 0.2s ease;
285
+ }
286
+
287
+ .notification-detail-content {
288
+ font-size: 0.85rem;
289
+ line-height: 1.4;
290
+ color: var(--color-text);
291
+ }
292
+
293
+ /* Empty State */
294
+ .notification-empty {
295
+ text-align: center;
296
+ padding: 3rem 1rem;
297
+ color: var(--color-text);
298
+ opacity: 0.6;
299
+ }
300
+
301
+ .notification-empty-icon {
302
+ font-size: 3rem;
303
+ margin-bottom: 1rem;
304
+ opacity: 0.5;
305
+ }
306
+
307
+ /* Animations */
308
+ @keyframes spin {
309
+ from { transform: rotate(0deg); }
310
+ to { transform: rotate(360deg); }
311
+ }
312
+
313
+ /* Light Mode Styles */
314
+ .light-mode .notification-item {
315
+ background: rgba(0, 0, 0, 0.03);
316
+ border-color: rgba(0, 0, 0, 0.1);
317
+ }
318
+
319
+ .light-mode .notification-item:hover {
320
+ background: rgba(0, 0, 0, 0.06);
321
+ border-color: rgba(0, 0, 0, 0.15);
322
+ }
323
+
324
+ .light-mode .notification-action {
325
+ border-color: rgba(0, 0, 0, 0.2);
326
+ color: var(--color-text);
327
+ }
328
+
329
+ .light-mode .notification-action:hover {
330
+ background: rgba(0, 0, 0, 0.05);
331
+ border-color: rgba(0, 0, 0, 0.3);
332
+ }
333
+
334
+ .light-mode .notification-detail {
335
+ background: rgba(0, 0, 0, 0.05);
336
+ border-left-color: rgba(0, 0, 0, 0.15);
337
+ }
338
+ </style>
339
+
340
+ <script>
341
+ function notificationModalComponent() {
342
+ return {
343
+ init() {
344
+ // Initialize component when modal loads
345
+ console.log('Notification modal component initialized');
346
+
347
+ // Mark all notifications as read when modal opens
348
+ // This can be configured later if needed
349
+ setTimeout(() => {
350
+ if (this.$store.notificationStore) {
351
+ this.$store.notificationStore.markAllAsRead();
352
+ }
353
+ }, 1000);
354
+ }
355
+ };
356
+ }
357
+ </script>
358
+</body>
359
+</html>
webui/components/notifications/notification-toast-stack.html
new
+245
@@ -0,0 +1,245 @@
1
+<html>
2
+<head>
3
+ <title>Notification Toast Stack</title>
4
+ <script type="module">
5
+ import { store } from "/js/notificationStore.js";
6
+ </script>
7
+</head>
8
+<body>
9
+ <div x-data>
10
+ <!-- Toast Stack Container -->
11
+ <div class="toast-stack-container"
12
+ x-show="$store.notificationStore.toastStack.length > 0">
13
+
14
+ <template x-for="(toast, index) in $store.notificationStore.toastStack" :key="toast.toastId">
15
+ <div class="toast-item"
16
+ :class="$store.notificationStore.getNotificationClass(toast.type)"
17
+ @click="$store.notificationStore.handleToastClick(toast.toastId)"
18
+ x-transition:enter="toast-enter"
19
+ x-transition:leave="toast-leave">
20
+
21
+ <!-- Toast Icon -->
22
+ <div class="toast-icon"
23
+ x-html="$store.notificationStore.getNotificationIcon(toast.type)">
24
+ </div>
25
+
26
+ <!-- Toast Content -->
27
+ <div class="toast-content">
28
+ <div class="toast-title"
29
+ x-show="toast.title"
30
+ x-text="toast.title">
31
+ </div>
32
+ <div class="toast-message"
33
+ x-text="toast.message">
34
+ </div>
35
+ <div class="toast-timestamp"
36
+ x-text="$store.notificationStore.formatTimestamp(toast.timestamp)">
37
+ </div>
38
+ </div>
39
+
40
+ <!-- Toast Dismiss -->
41
+ <button class="toast-dismiss"
42
+ @click.stop="$store.notificationStore.removeFromToastStack(toast.toastId)"
43
+ title="Dismiss">
44
+ ✕
45
+ </button>
46
+ </div>
47
+ </template>
48
+ </div>
49
+ </div>
50
+
51
+ <style>
52
+ /* Toast Stack Container */
53
+ .toast-stack-container {
54
+ position: fixed;
55
+ bottom: 20px;
56
+ right: 20px;
57
+ z-index: 1500;
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 8px;
61
+ pointer-events: none;
62
+ max-width: 400px;
63
+ width: 100%;
64
+ }
65
+
66
+ /* Individual Toast Items */
67
+ .toast-item {
68
+ pointer-events: auto;
69
+ display: flex;
70
+ align-items: flex-start;
71
+ gap: 12px;
72
+ padding: 16px;
73
+ background: rgba(0, 0, 0, 0.9);
74
+ border: 1px solid rgba(255, 255, 255, 0.2);
75
+ border-radius: 8px;
76
+ cursor: pointer;
77
+ transition: all 0.2s ease;
78
+ backdrop-filter: blur(10px);
79
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
80
+ position: relative;
81
+ min-height: 60px;
82
+ }
83
+
84
+ .toast-item:hover {
85
+ background: rgba(0, 0, 0, 0.95);
86
+ border-color: rgba(255, 255, 255, 0.3);
87
+ transform: translateY(-2px);
88
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
89
+ }
90
+
91
+ /* Toast Type Styling */
92
+ .toast-item.notification-info {
93
+ border-left: 4px solid #2196F3;
94
+ background: rgba(33, 150, 243, 0.1);
95
+ }
96
+
97
+ .toast-item.notification-success {
98
+ border-left: 4px solid #4CAF50;
99
+ background: rgba(76, 175, 80, 0.1);
100
+ }
101
+
102
+ .toast-item.notification-warning {
103
+ border-left: 4px solid #FF9800;
104
+ background: rgba(255, 152, 0, 0.1);
105
+ }
106
+
107
+ .toast-item.notification-error {
108
+ border-left: 4px solid #F44336;
109
+ background: rgba(244, 67, 54, 0.1);
110
+ }
111
+
112
+ .toast-item.notification-progress {
113
+ border-left: 4px solid #9C27B0;
114
+ background: rgba(156, 39, 176, 0.1);
115
+ }
116
+
117
+ /* Toast Icon */
118
+ .toast-icon {
119
+ font-size: 1.2rem;
120
+ line-height: 1;
121
+ opacity: 0.9;
122
+ min-width: 24px;
123
+ text-align: center;
124
+ margin-top: 2px;
125
+ }
126
+
127
+ /* Toast Content */
128
+ .toast-content {
129
+ flex: 1;
130
+ min-width: 0;
131
+ }
132
+
133
+ .toast-title {
134
+ font-weight: 600;
135
+ font-size: 0.9rem;
136
+ color: var(--color-primary);
137
+ margin-bottom: 4px;
138
+ line-height: 1.3;
139
+ }
140
+
141
+ .toast-message {
142
+ font-size: 0.85rem;
143
+ color: var(--color-text);
144
+ line-height: 1.4;
145
+ margin-bottom: 4px;
146
+ }
147
+
148
+ .toast-timestamp {
149
+ font-size: 0.75rem;
150
+ color: var(--color-text);
151
+ opacity: 0.6;
152
+ }
153
+
154
+ /* Toast Dismiss */
155
+ .toast-dismiss {
156
+ background: rgba(255, 255, 255, 0.1);
157
+ border: none;
158
+ border-radius: 4px;
159
+ color: var(--color-text);
160
+ cursor: pointer;
161
+ padding: 4px 6px;
162
+ font-size: 0.8rem;
163
+ transition: all 0.2s ease;
164
+ opacity: 0;
165
+ }
166
+
167
+ .toast-item:hover .toast-dismiss {
168
+ opacity: 1;
169
+ }
170
+
171
+ .toast-dismiss:hover {
172
+ background: rgba(255, 255, 255, 0.2);
173
+ color: #fff;
174
+ }
175
+
176
+ /* Toast Animations */
177
+ .toast-enter {
178
+ transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
179
+ opacity: 0;
180
+ transform: translateX(100%) scale(0.8);
181
+ }
182
+
183
+ .toast-leave {
184
+ transition: all 0.2s ease;
185
+ opacity: 0;
186
+ transform: translateX(100%) scale(0.8);
187
+ }
188
+
189
+ /* Light Mode Styles */
190
+ .light-mode .toast-item {
191
+ background: rgba(255, 255, 255, 0.95);
192
+ border-color: rgba(0, 0, 0, 0.1);
193
+ color: var(--color-text);
194
+ }
195
+
196
+ .light-mode .toast-item:hover {
197
+ background: rgba(255, 255, 255, 0.98);
198
+ border-color: rgba(0, 0, 0, 0.2);
199
+ }
200
+
201
+ .light-mode .toast-dismiss {
202
+ background: rgba(0, 0, 0, 0.1);
203
+ }
204
+
205
+ .light-mode .toast-dismiss:hover {
206
+ background: rgba(0, 0, 0, 0.2);
207
+ }
208
+
209
+ /* Mobile Responsive */
210
+ @media (max-width: 768px) {
211
+ .toast-stack-container {
212
+ bottom: 10px;
213
+ right: 10px;
214
+ left: 10px;
215
+ max-width: none;
216
+ }
217
+
218
+ .toast-item {
219
+ padding: 12px;
220
+ font-size: 0.9rem;
221
+ }
222
+
223
+ .toast-title {
224
+ font-size: 0.85rem;
225
+ }
226
+
227
+ .toast-message {
228
+ font-size: 0.8rem;
229
+ }
230
+ }
231
+
232
+ /* Accessibility */
233
+ @media (prefers-reduced-motion: reduce) {
234
+ .toast-enter,
235
+ .toast-leave {
236
+ transition: none;
237
+ }
238
+
239
+ .toast-item:hover {
240
+ transform: none;
241
+ }
242
+ }
243
+ </style>
244
+</body>
245
+</html>
webui/css/notification.css
new
+156
@@ -0,0 +1,156 @@
1
+/* ===== NOTIFICATION SYSTEM STYLES ===== */
2
+
3
+/* Notification Toggle Button */
4
+.notification-toggle {
5
+ display: inline-flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ position: relative;
9
+ padding: 0;
10
+ background: rgba(255, 255, 255, 0.05);
11
+ border: 1px solid rgba(255, 255, 255, 0.1);
12
+ border-radius: 6px;
13
+ color: var(--color-text);
14
+ cursor: pointer;
15
+ transition: all 0.2s ease;
16
+ font-size: 1rem;
17
+ width: 36px;
18
+ height: 36px;
19
+ flex-shrink: 0;
20
+ margin: 0;
21
+ box-sizing: border-box;
22
+ text-align: center;
23
+}
24
+
25
+.notification-toggle:hover {
26
+ background: rgba(255, 255, 255, 0.1);
27
+ border-color: rgba(255, 255, 255, 0.2);
28
+ transform: scale(1.05);
29
+}
30
+
31
+.notification-toggle .notification-icon {
32
+ display: flex;
33
+ align-items: center;
34
+ justify-content: center;
35
+ font-size: 1rem;
36
+ width: 100%;
37
+ height: 100%;
38
+ text-align: center;
39
+ line-height: 1;
40
+ vertical-align: middle;
41
+ margin: 0;
42
+ padding: 0;
43
+}
44
+
45
+.notification-toggle.has-unread {
46
+ border-color: #2196F3;
47
+ background: rgba(33, 150, 243, 0.1);
48
+ animation: pulse 2s infinite;
49
+}
50
+
51
+.notification-badge {
52
+ position: absolute;
53
+ top: -8px;
54
+ right: -8px;
55
+ background: #F44336;
56
+ color: white;
57
+ font-size: 0.7rem;
58
+ font-weight: bold;
59
+ padding: 0.2rem 0.4rem;
60
+ border-radius: 10px;
61
+ min-width: 1.2rem;
62
+ text-align: center;
63
+ line-height: 1;
64
+ border: 2px solid var(--bg-color);
65
+ z-index: 1;
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+}
70
+
71
+.notification-toggle.disabled {
72
+ opacity: 0.4;
73
+ cursor: not-allowed;
74
+}
75
+
76
+.notification-toggle.has-notifications {
77
+ opacity: 1;
78
+}
79
+
80
+.notification-toggle.disabled:hover {
81
+ background: rgba(255, 255, 255, 0.05);
82
+ border-color: rgba(255, 255, 255, 0.1);
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
+}
102
+
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
+/* Light Mode Styles */
113
+.light-mode .notification-toggle {
114
+ background: rgba(0, 0, 0, 0.05);
115
+ border-color: rgba(0, 0, 0, 0.1);
116
+}
117
+
118
+.light-mode .notification-toggle:hover {
119
+ background: rgba(0, 0, 0, 0.1);
120
+ border-color: rgba(0, 0, 0, 0.2);
121
+}
122
+
123
+.light-mode .notification-toggle.has-unread {
124
+ border-color: #2196F3;
125
+ background: rgba(33, 150, 243, 0.1);
126
+}
127
+
128
+.light-mode .notification-toggle.disabled:hover {
129
+ background: rgba(0, 0, 0, 0.05);
130
+ border-color: rgba(0, 0, 0, 0.1);
131
+}
132
+
133
+.light-mode .notification-test-button {
134
+ background: rgba(33, 150, 243, 0.1);
135
+ border-color: rgba(33, 150, 243, 0.3);
136
+}
137
+
138
+.light-mode .notification-test-button:hover {
139
+ background: rgba(33, 150, 243, 0.2);
140
+ border-color: rgba(33, 150, 243, 0.5);
141
+}
142
+
143
+/* Animations */
144
+@keyframes pulse {
145
+ 0%, 100% { transform: scale(1); }
146
+ 50% { transform: scale(1.05); }
147
+}
148
+
149
+/* Mobile Responsive */
150
+@media (max-width: 768px) {
151
+ .notification-toggle {
152
+ min-width: 40px;
153
+ min-height: 40px;
154
+ padding: 0.4rem;
155
+ }
156
+}
webui/index.css
+3
-4
@@ -198,7 +198,7 @@ img {
198
padding: 0.47rem 0.56rem;
199
position: absolute;
200
top: var(--spacing-md);
201
- z-index: 1004;
201
+ z-index: 999;
202
-webkit-transition: all var(--transition-speed) ease-in-out;
203
transition: all var(--transition-speed) ease-in-out;
204
}
@@ -403,11 +403,10 @@ img {
403
404
#time-date-container {
405
position: fixed;
406
- right: 0;
406
+ right: var(--spacing-md);
407
display: flex;
408
align-items: center;
409
gap: var(--spacing-sm);
410
- margin-right: var(--spacing-md);
410
margin-top: var(--spacing-md);
411
}
412
@@ -466,7 +465,7 @@ pre {
465
position: fixed;
466
margin-left: 4.6rem;
467
margin-top: var(--spacing-md);
469
- z-index: 1004;
468
+ z-index: 999;
469
-webkit-transition: margin-left var(--transition-speed) ease-in-out;
470
transition: margin-left var(--transition-speed) ease-in-out;
471
}
webui/index.html
+9
-1
@@ -17,6 +17,7 @@
17
<link rel="stylesheet" href="css/history.css">
18
<link rel="stylesheet" href="css/scheduler-datepicker.css">
19
<link rel="stylesheet" href="css/tunnel.css">
20
+ <link rel="stylesheet" href="css/notification.css">
21
22
<!-- Font Awesome for icons -->
23
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
@@ -102,6 +103,7 @@
103
<script type="module" src="js/scheduler.js"></script>
104
<script type="module" src="js/speech.js"></script>
105
<script type="module" src="js/history.js"></script>
106
+ <script type="module" src="js/notificationStore.js"></script>
107
<script type="module" src="index.js"></script>
108
109
<!-- Then load Alpine.js -->
@@ -121,7 +123,7 @@
123
124
<!-- Google Icons -->
125
<link rel="stylesheet" href="vendor/google/google-icons.css" />
124
-
126
+
127
128
<!-- Non-module scripts after Alpine.js -->
129
<script type="text/javascript" src="js/settings.js"></script>
@@ -349,9 +351,15 @@
351
stroke-width="3" x-bind:opacity="connected ? 0 : 1" />
352
</svg>
353
</div>
354
+ <!-- Notification Toggle positioned next to time-date -->
355
+ <x-component path="notifications/notification-icons.html"></x-component>
356
</div>
357
<div id="chat-history">
358
</div>
359
+
360
+ <!-- NEW: Toast Stack Component -->
361
+ <x-component path="notifications/notification-toast-stack.html"></x-component>
362
+
363
<div id="toast" class="toast">
364
<div class="toast__content">
365
<div class="toast__title"></div>
webui/index.js
+6
@@ -331,6 +331,7 @@ async function poll() {
331
332
const response = await sendJsonData("/poll", {
333
log_from: lastLogVersion,
334
+ notifications_from: globalThis.Alpine?.store('notificationStore')?.lastNotificationVersion || 0,
335
context: context || null,
336
timezone: timezone,
337
});
@@ -370,6 +371,11 @@ async function poll() {
371
372
updateProgress(response.log_progress, response.log_progress_active);
373
374
+ // Update notifications from response
375
+ if (globalThis.Alpine?.store('notificationStore')) {
376
+ globalThis.Alpine.store('notificationStore').updateFromPoll(response);
377
+ }
378
+
379
//set ui model vars from backend
380
if (window.Alpine && inputSection) {
381
const inputAD = Alpine.$data(inputSection);
webui/js/initFw.js
+11
@@ -1,5 +1,6 @@
1
import * as _modals from "./modals.js";
2
import * as _components from "./components.js";
3
+import "./notificationStore.js";
4
5
await import("../vendor/alpine/alpine.min.js");
6
@@ -11,3 +12,13 @@ Alpine.directive(
12
cleanup(() => onDestroy());
13
}
14
);
15
+
16
+// Initialize notification store when Alpine is ready
17
+document.addEventListener('alpine:init', () => {
18
+ // Initialize the notification store
19
+ setTimeout(() => {
20
+ if (Alpine.store('notificationStore')) {
21
+ Alpine.store('notificationStore').initialize();
22
+ }
23
+ }, 100);
24
+});
webui/js/notificationStore.js
new
+374
@@ -0,0 +1,374 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import * as API from "/js/api.js";
3
+
4
+const model = {
5
+ notifications: [],
6
+ loading: false,
7
+ lastNotificationVersion: 0,
8
+ lastNotificationGuid: "",
9
+ unreadCount: 0,
10
+
11
+ // NEW: Toast stack management
12
+ toastStack: [],
13
+ maxToastStack: 5,
14
+
15
+ // Initialize the notification store
16
+ initialize() {
17
+ console.log("NotificationStore: Initializing with toast stack");
18
+ this.loading = true;
19
+ this.updateUnreadCount();
20
+ this.removeOldNotifications();
21
+ this.toastStack = [];
22
+
23
+ // Auto-cleanup old notifications and toasts
24
+ setInterval(() => {
25
+ this.removeOldNotifications();
26
+ this.cleanupExpiredToasts();
27
+ }, 5 * 60 * 1000); // Every 5 minutes
28
+ },
29
+
30
+ // Update notifications from polling data
31
+ updateFromPoll(pollData) {
32
+ if (!pollData) return;
33
+
34
+ // Check if GUID changed (system restart)
35
+ if (pollData.notifications_guid !== this.lastNotificationGuid) {
36
+ this.lastNotificationVersion = 0;
37
+ this.notifications = [];
38
+ this.toastStack = []; // Clear toast stack on restart
39
+ this.lastNotificationGuid = pollData.notifications_guid || '';
40
+ }
41
+
42
+ // Process new notifications and add to toast stack
43
+ if (pollData.notifications && pollData.notifications.length > 0) {
44
+ pollData.notifications.forEach(notification => {
45
+ const isNew = !this.notifications.find(n => n.id === notification.id);
46
+ this.addOrUpdateNotification(notification);
47
+
48
+ // Add new notifications to toast stack
49
+ if (isNew && !notification.read) {
50
+ this.addToToastStack(notification);
51
+ }
52
+ });
53
+ }
54
+
55
+ // Update version tracking
56
+ this.lastNotificationVersion = pollData.notifications_version || 0;
57
+ this.lastNotificationGuid = pollData.notifications_guid || '';
58
+
59
+ // Update UI state
60
+ this.updateUnreadCount();
61
+ this.removeOldNotifications();
62
+
63
+ // Limit notifications to prevent memory issues (keep most recent)
64
+ if (this.notifications.length > 50) {
65
+ // Sort by timestamp and keep newest 50
66
+ this.notifications.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
67
+ this.notifications = this.notifications.slice(0, 50);
68
+ }
69
+ },
70
+
71
+ // NEW: Add notification to toast stack
72
+ addToToastStack(notification) {
73
+ // Create toast object with auto-dismiss timer
74
+ const toast = {
75
+ ...notification,
76
+ toastId: `toast-${notification.id}`,
77
+ addedAt: Date.now(),
78
+ autoRemoveTimer: null
79
+ };
80
+
81
+ // Add to bottom of stack (newest at bottom)
82
+ this.toastStack.push(toast);
83
+
84
+ // Enforce max stack limit (remove oldest from top)
85
+ if (this.toastStack.length > this.maxToastStack) {
86
+ const removed = this.toastStack.shift(); // Remove from top
87
+ if (removed.autoRemoveTimer) {
88
+ clearTimeout(removed.autoRemoveTimer);
89
+ }
90
+ }
91
+
92
+ // Set auto-dismiss timer
93
+ toast.autoRemoveTimer = setTimeout(() => {
94
+ this.removeFromToastStack(toast.toastId);
95
+ }, notification.display_time * 1000);
96
+
97
+ console.log(`Toast added: ${notification.type} - ${notification.message}`);
98
+ },
99
+
100
+ // NEW: Remove toast from stack
101
+ removeFromToastStack(toastId) {
102
+ const index = this.toastStack.findIndex(t => t.toastId === toastId);
103
+ if (index >= 0) {
104
+ const toast = this.toastStack[index];
105
+ if (toast.autoRemoveTimer) {
106
+ clearTimeout(toast.autoRemoveTimer);
107
+ }
108
+ this.toastStack.splice(index, 1);
109
+ console.log(`Toast removed: ${toastId}`);
110
+ }
111
+ },
112
+
113
+ // NEW: Clear entire toast stack
114
+ clearToastStack() {
115
+ this.toastStack.forEach(toast => {
116
+ if (toast.autoRemoveTimer) {
117
+ clearTimeout(toast.autoRemoveTimer);
118
+ }
119
+ });
120
+ this.toastStack = [];
121
+ console.log('Toast stack cleared');
122
+ },
123
+
124
+ // NEW: Clean up expired toasts (backup cleanup)
125
+ cleanupExpiredToasts() {
126
+ const now = Date.now();
127
+ this.toastStack = this.toastStack.filter(toast => {
128
+ const age = now - toast.addedAt;
129
+ const maxAge = toast.display_time * 1000;
130
+
131
+ if (age > maxAge) {
132
+ if (toast.autoRemoveTimer) {
133
+ clearTimeout(toast.autoRemoveTimer);
134
+ }
135
+ return false;
136
+ }
137
+ return true;
138
+ });
139
+ },
140
+
141
+ // NEW: Handle toast click (opens modal)
142
+ async handleToastClick(toastId) {
143
+ console.log(`Toast clicked: ${toastId}`);
144
+ await this.openModal();
145
+ // Modal opening will clear toast stack via markAllAsRead
146
+ },
147
+
148
+ // Add or update a notification
149
+ addOrUpdateNotification(notification) {
150
+ const existingIndex = this.notifications.findIndex(n => n.id === notification.id);
151
+
152
+ if (existingIndex >= 0) {
153
+ // Update existing notification
154
+ this.notifications[existingIndex] = notification;
155
+ } else {
156
+ // Add new notification at the beginning (most recent first)
157
+ this.notifications.unshift(notification);
158
+ }
159
+ },
160
+
161
+ // Update unread count
162
+ updateUnreadCount() {
163
+ this.unreadCount = this.notifications.filter(n => !n.read).length;
164
+ },
165
+
166
+ // Mark notification as read
167
+ async markAsRead(notificationId) {
168
+ const notification = this.notifications.find(n => n.id === notificationId);
169
+ if (notification && !notification.read) {
170
+ notification.read = true;
171
+ this.updateUnreadCount();
172
+
173
+ // Sync with backend (non-blocking)
174
+ try {
175
+ await API.callJsonApi('notifications_mark_read', {
176
+ notification_ids: [notificationId]
177
+ });
178
+ } catch (error) {
179
+ console.error('Failed to sync notification read status:', error);
180
+ // Don't revert the UI change - user experience should not be affected
181
+ }
182
+ }
183
+ },
184
+
185
+ // Enhanced: Mark all as read and clear toast stack
186
+ async markAllAsRead() {
187
+ const unreadNotifications = this.notifications.filter(n => !n.read);
188
+ if (unreadNotifications.length === 0) return;
189
+
190
+ // Update UI immediately
191
+ this.notifications.forEach(notification => {
192
+ notification.read = true;
193
+ });
194
+ this.updateUnreadCount();
195
+
196
+ // Clear toast stack when marking all as read
197
+ this.clearToastStack();
198
+
199
+ // Sync with backend (non-blocking)
200
+ try {
201
+ await API.callJsonApi('notifications_mark_read', {
202
+ mark_all: true
203
+ });
204
+ } catch (error) {
205
+ console.error('Failed to sync mark all as read:', error);
206
+ }
207
+ },
208
+
209
+ // Clear all notifications
210
+ async clearAll() {
211
+ this.notifications = [];
212
+ this.unreadCount = 0;
213
+ this.clearToastStack(); // Also clear toast stack
214
+
215
+ // Note: We don't sync clear with backend as notifications are stored in memory only
216
+ console.log('All notifications cleared');
217
+ },
218
+
219
+ // Get notifications by type
220
+ getNotificationsByType(type) {
221
+ return this.notifications.filter(n => n.type === type);
222
+ },
223
+
224
+ // Get notifications for display: ALL unread + read from last 5 minutes
225
+ getDisplayNotifications() {
226
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
227
+
228
+ return this.notifications.filter(notification => {
229
+ // Always show unread notifications
230
+ if (!notification.read) {
231
+ return true;
232
+ }
233
+
234
+ // Show read notifications only if they're from the last 5 minutes
235
+ const notificationDate = new Date(notification.timestamp);
236
+ return notificationDate > fiveMinutesAgo;
237
+ });
238
+ },
239
+
240
+ // Get recent notifications (last 5) - kept for backwards compatibility
241
+ getRecentNotifications() {
242
+ return this.notifications.slice(0, 5);
243
+ },
244
+
245
+ // Get notification by ID
246
+ getNotificationById(id) {
247
+ return this.notifications.find(n => n.id === id);
248
+ },
249
+
250
+ // Remove old notifications (older than 1 hour)
251
+ removeOldNotifications() {
252
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
253
+ const initialCount = this.notifications.length;
254
+ this.notifications = this.notifications.filter(n =>
255
+ new Date(n.timestamp) > oneHourAgo
256
+ );
257
+
258
+ if (this.notifications.length !== initialCount) {
259
+ this.updateUnreadCount();
260
+ }
261
+ },
262
+
263
+ // Format timestamp for display
264
+ formatTimestamp(timestamp) {
265
+ const date = new Date(timestamp);
266
+ const now = new Date();
267
+ const diffMs = now - date;
268
+ const diffMins = Math.floor(diffMs / 60000);
269
+ const diffHours = Math.floor(diffMs / 3600000);
270
+ const diffDays = Math.floor(diffMs / 86400000);
271
+
272
+ if (diffMins < 1) return 'Just now';
273
+ if (diffMins < 60) return `${diffMins}m ago`;
274
+ if (diffHours < 24) return `${diffHours}h ago`;
275
+ if (diffDays < 7) return `${diffDays}d ago`;
276
+
277
+ return date.toLocaleDateString();
278
+ },
279
+
280
+ // Get CSS class for notification type
281
+ getNotificationClass(type) {
282
+ const classes = {
283
+ info: "notification-info",
284
+ success: "notification-success",
285
+ warning: "notification-warning",
286
+ error: "notification-error",
287
+ progress: "notification-progress"
288
+ };
289
+ return classes[type] || "notification-info";
290
+ },
291
+
292
+ // Get CSS class for notification item including read state
293
+ getNotificationItemClass(notification) {
294
+ const typeClass = this.getNotificationClass(notification.type);
295
+ const readClass = notification.read ? "read" : "unread";
296
+ return `notification-item ${typeClass} ${readClass}`;
297
+ },
298
+
299
+ // Get icon for notification type
300
+ getNotificationIcon(type) {
301
+ const icons = {
302
+ info: "ℹ️",
303
+ success: "✅",
304
+ warning: "⚠️",
305
+ error: "❌",
306
+ progress: "⏳"
307
+ };
308
+ return icons[type] || "ℹ️";
309
+ },
310
+
311
+ // Create notification via backend (will appear via polling)
312
+ async createNotification(type, message, title = "", detail = "", display_time = 3) {
313
+ try {
314
+ const response = await window.sendJsonData('/notification_create', {
315
+ type: type,
316
+ message: message,
317
+ title: title,
318
+ detail: detail,
319
+ display_time: display_time
320
+ });
321
+
322
+ if (response.success) {
323
+ console.log('Notification created:', response.notification_id);
324
+ return response.notification_id;
325
+ } else {
326
+ console.error('Failed to create notification:', response.error);
327
+ return null;
328
+ }
329
+ } catch (error) {
330
+ console.error('Error creating notification:', error);
331
+ return null;
332
+ }
333
+ },
334
+
335
+ // 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);
338
+ },
339
+
340
+ async success(message, title = "", detail = "", display_time = 3) {
341
+ return await this.createNotification('success', message, title, detail, display_time);
342
+ },
343
+
344
+ async warning(message, title = "", detail = "", display_time = 3) {
345
+ return await this.createNotification('warning', message, title, detail, display_time);
346
+ },
347
+
348
+ async error(message, title = "", detail = "", display_time = 3) {
349
+ return await this.createNotification('error', message, title, detail, display_time);
350
+ },
351
+
352
+ async progress(message, title = "", detail = "", display_time = 3) {
353
+ return await this.createNotification('progress', message, title, detail, display_time);
354
+ },
355
+
356
+ // Enhanced: Open modal and clear toast stack
357
+ async openModal() {
358
+ // Import the standard modal system
359
+ const { openModal } = await import("/js/modals.js");
360
+ await openModal("notifications/notification-modal.html");
361
+
362
+ // Clear toast stack when modal opens
363
+ this.clearToastStack();
364
+ },
365
+
366
+ // Legacy method for backward compatibility
367
+ toggleNotifications() {
368
+ this.openModal();
369
+ }
370
+};
371
+
372
+// Create and export the store
373
+const store = createStore("notificationStore", model);
374
+export { store };