Add tool request validation and plugin change notifications
Introduce validate_tool_request() extensible method in agent.py to validate tool request structure (dict with tool_name string and tool_args dict fields) before processing. Add after_plugin_change() helper in helpers/plugins.py that clears cache and sends a frontend reload notification (throttled to display_time interval) with a reload button. Update plugin installer install/delete flows to call after_plugin_change(). Extend notification
frdel committed
Mar 10, 2026 at 13:08 UTC
1b89a0d35923136d0014e0dded7a13e4b20265c1
24 files changed
+315
-137
agent.py
+14
@@ -854,6 +854,9 @@ class Agent:
854
# search for tool usage requests in agent message
855
tool_request = extract_tools.json_parse_dirty(msg)
856
857
+ # basic validation + extensions
858
+ await self.validate_tool_request(tool_request)
859
+
860
if tool_request is not None:
861
raw_tool_name = tool_request.get("tool_name", tool_request.get("tool","")) # Get the raw tool name
862
tool_args = tool_request.get("tool_args", tool_request.get("args", {}))
@@ -948,6 +951,17 @@ class Agent:
951
content=f"{self.agent_name}: Message misformat, no valid tool request found.",
952
)
953
954
+ @extension.extensible
955
+ async def validate_tool_request(self, tool_request: Any):
956
+ if not isinstance(tool_request, dict):
957
+ raise ValueError("Tool request must be a dictionary")
958
+ if not tool_request.get("tool_name") or not isinstance(tool_request.get("tool_name"), str):
959
+ raise ValueError("Tool request must have a tool_name (type string) field")
960
+ if not tool_request.get("tool_args") or not isinstance(tool_request.get("tool_args"), dict):
961
+ raise ValueError("Tool request must have a tool_args (type dictionary) field")
962
+
963
+
964
+
965
async def handle_reasoning_stream(self, stream: str):
966
await self.handle_intervention()
967
await extension.call_extensions_async(
api/notification_create.py
+2
@@ -17,6 +17,7 @@ class NotificationCreate(ApiHandler):
17
detail = input.get("detail", "")
18
display_time = input.get("display_time", 3) # Default to 3 seconds
19
group = input.get("group", "") # Group parameter for notification grouping
20
+ notification_id = input.get("id", "")
21
22
# Validate required fields
23
if not message:
@@ -50,6 +51,7 @@ class NotificationCreate(ApiHandler):
51
detail,
52
display_time,
53
group,
54
+ notification_id,
55
)
56
57
return {
helpers/notification.py
+37
-20
@@ -77,10 +77,11 @@ class NotificationManager:
77
detail: str = "",
78
display_time: int = 3,
79
group: str = "",
80
+ id: str = "",
81
) -> NotificationItem:
82
from agent import AgentContext
83
return AgentContext.get_notification_manager().add_notification(
83
- type, priority, message, title, detail, display_time, group
84
+ type, priority, message, title, detail, display_time, group, id
85
)
86
87
def add_notification(
@@ -92,28 +93,44 @@ class NotificationManager:
93
detail: str = "",
94
display_time: int = 3,
95
group: str = "",
96
+ id: str = "",
97
) -> NotificationItem:
98
with self._lock:
99
+ existing = None
100
+ if id:
101
+ existing = next((n for n in self.notifications if n.id == id), None)
102
+
103
+ if existing:
104
+ existing.type = NotificationType(type)
105
+ existing.priority = NotificationPriority(priority)
106
+ existing.title = title
107
+ existing.message = message
108
+ existing.detail = detail
109
+ existing.timestamp = datetime.now(timezone.utc)
110
+ existing.display_time = display_time
111
+ existing.group = group
112
+ existing.read = False
113
+ self.updates.append(existing.no)
114
+ item = existing
115
+ else:
116
# Create notification item
98
- item = NotificationItem(
99
- manager=self,
100
- no=len(self.notifications),
101
- type=NotificationType(type),
102
- priority=NotificationPriority(priority),
103
- title=title,
104
- message=message,
105
- detail=detail,
106
- timestamp=datetime.now(timezone.utc),
107
- display_time=display_time,
108
- group=group,
109
- )
110
-
111
- # Add to notifications
112
- self.notifications.append(item)
113
- self.updates.append(item.no)
114
-
115
- # Enforce limit
116
- self._enforce_limit()
117
+ item = NotificationItem(
118
+ manager=self,
119
+ no=len(self.notifications),
120
+ type=NotificationType(type),
121
+ priority=NotificationPriority(priority),
122
+ title=title,
123
+ message=message,
124
+ detail=detail,
125
+ timestamp=datetime.now(timezone.utc),
126
+ display_time=display_time,
127
+ id=id,
128
+ group=group,
129
+ )
130
+
131
+ self.notifications.append(item)
132
+ self.updates.append(item.no)
133
+ self._enforce_limit()
134
135
from helpers.state_monitor_integration import mark_dirty_all
136
mark_dirty_all(reason="notification.NotificationManager.add_notification")
helpers/plugins.py
+36
-5
@@ -1,6 +1,7 @@
1
from __future__ import annotations
2
3
import re, json, glob
4
+import time
5
from pathlib import Path
6
from typing import (
7
Any,
@@ -13,7 +14,7 @@ from typing import (
14
TypedDict,
15
)
16
16
-from helpers import files, print_style, yaml as yaml_helper, cache
17
+from helpers import files, notification, print_style, yaml as yaml_helper, cache
18
from pydantic import BaseModel, Field
19
20
if TYPE_CHECKING:
@@ -40,6 +41,7 @@ CONFIG_DEFAULT_FILE_NAME = "default_config.yaml"
41
DISABLED_FILE_NAME = ".toggle-0"
42
ENABLED_FILE_NAME = ".toggle-1"
43
TOGGLE_FILE_PATTERN = ".toggle-[01]"
44
+_last_frontend_reload_notification_at = 0.0
45
46
47
class PluginMetadata(BaseModel):
@@ -71,6 +73,11 @@ class PluginListItem(BaseModel):
73
toggle_state: ToggleState = "disabled"
74
75
76
+def after_plugin_change():
77
+ clear_plugin_cache()
78
+ send_frontend_reload_notification()
79
+
80
+
81
def clear_plugin_cache():
82
cache.clear("*(plugins)*")
83
@@ -189,7 +196,7 @@ def delete_plugin(plugin_name: str):
196
if not files.is_in_dir(plugin_dir, custom_plugins_dir):
197
raise ValueError("Only custom plugins can be deleted")
198
files.delete_dir(plugin_dir)
192
- clear_plugin_cache()
199
+ after_plugin_change()
200
201
202
def get_plugin_paths(*subpaths: str) -> List[str]:
@@ -348,7 +355,7 @@ def toggle_plugin(
355
files.write_file(enabled_file, "")
356
else:
357
files.write_file(disabled_file, "")
351
- clear_plugin_cache()
358
+ after_plugin_change()
359
360
361
def get_plugin_config(
@@ -387,7 +394,9 @@ def get_plugin_config(
394
395
396
def get_default_plugin_config(plugin_name: str):
390
- file_path = files.get_abs_path(find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME)
397
+ file_path = files.get_abs_path(
398
+ find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
399
+ )
400
if file_path and files.exists(file_path):
401
return (
402
json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
@@ -403,7 +412,7 @@ def save_plugin_config(
412
)
413
if file_path:
414
files.write_file(file_path, json.dumps(settings))
406
- clear_plugin_cache()
415
+ after_plugin_change()
416
417
418
def find_plugin_asset(
@@ -548,3 +557,25 @@ def determine_plugin_asset_path(
557
base_path = files.get_abs_path(base_path, files.AGENTS_DIR, agent_profile)
558
559
return files.get_abs_path(base_path, files.PLUGINS_DIR, plugin_name, *subpaths)
560
+
561
+
562
+def send_frontend_reload_notification():
563
+ global _last_frontend_reload_notification_at
564
+
565
+ display_time = 3
566
+ now = time.monotonic()
567
+ if now - _last_frontend_reload_notification_at < display_time:
568
+ return
569
+
570
+ _last_frontend_reload_notification_at = now
571
+
572
+ notification.NotificationManager.send_notification(
573
+ type=notification.NotificationType.INFO,
574
+ priority=notification.NotificationPriority.NORMAL,
575
+ title="Plugins updated, page reload recommended",
576
+ message="""<button type="button" class="button confirm" onclick="window.location.reload()"><span class="icon material-symbols-outlined">refresh</span>Reload page</button>""",
577
+ detail="",
578
+ display_time=display_time,
579
+ group="plugins_changed",
580
+ id="plugins_frontend_reload",
581
+ )
plugins/error_retry/default_config.yaml
new
+1
@@ -0,0 +1 @@
1
+retries: 1
\ No newline at end of file
plugins/error_retry/webui/config.html
new
+32
@@ -0,0 +1,32 @@
1
+<html>
2
+<head>
3
+ <title>Error retry</title>
4
+</head>
5
+
6
+<body>
7
+ <div x-data>
8
+ <template x-if="config">
9
+ <div>
10
+ <div class="section-title">Error retry</div>
11
+ <div class="section-description">
12
+ Settings for retrying failed operations.
13
+ </div>
14
+
15
+ <div class="field">
16
+ <div class="field-label">
17
+ <div class="field-title">Retries</div>
18
+ <div class="field-description">
19
+ Number of retries after an error occurs.
20
+ </div>
21
+ </div>
22
+ <div class="field-control">
23
+ <input type="number" min="0"
24
+ x-model.number="config.retries" />
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </template>
29
+ </div>
30
+</body>
31
+
32
+</html>
plugins/memory/webui/memory-detail-modal.html
-39
@@ -163,45 +163,6 @@
163
border: 1px solid;
164
}
165
166
- .btn-action-header {
167
- padding: 0.4rem 0.6rem;
168
- display: flex;
169
- align-items: center;
170
- gap: 0.25rem;
171
- background: var(--color-message-bg);
172
- color: var(--color-text);
173
- border: 1px solid var(--color-border);
174
- cursor: pointer;
175
- }
176
-
177
- .btn-action-header .material-symbols-outlined {
178
- font-size: 18px;
179
- }
180
-
181
- .btn-action-header.delete:hover {
182
- border-color: var(--color-accent);
183
- color: var(--color-accent);
184
- }
185
-
186
- .btn-action-header.copy-all:hover,
187
- .btn-action-header.copy-content:hover,
188
- .btn-action-header.edit:hover {
189
- border-color: var(--color-primary);
190
- color: var(--color-primary);
191
- }
192
-
193
- .btn-action-header.confirm:hover {
194
- border-color: #4CAF50;
195
- color: #4CAF50;
196
- background: var(--color-background);
197
- }
198
-
199
- .btn-action-header.cancel:hover {
200
- border-color: var(--color-accent);
201
- color: var(--color-accent);
202
- background: var(--color-background);
203
- }
204
-
166
.modal-body {
167
display: flex;
168
min-height: 60vh;
plugins/plugin_installer/helpers/install.py
+3
-3
@@ -18,7 +18,7 @@ from helpers.plugins import (
18
META_FILE_NAME,
19
PluginMetadata,
20
get_plugins_list,
21
- clear_plugin_cache,
21
+ after_plugin_change,
22
)
23
from werkzeug.datastructures import FileStorage
24
from werkzeug.utils import secure_filename
@@ -138,7 +138,7 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
138
dest = os.path.join(_get_user_plugins_dir(), plugin_name)
139
os.makedirs(os.path.dirname(dest), exist_ok=True)
140
shutil.move(plugin_root, dest)
141
- clear_plugin_cache()
141
+ after_plugin_change()
142
143
return {
144
"success": True,
@@ -181,7 +181,7 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
181
shutil.rmtree(dest, ignore_errors=True)
182
raise
183
184
- clear_plugin_cache()
184
+ after_plugin_change()
185
186
return {
187
"success": True,
webui/components/chat/input/chat-bar-input.html
+3
-5
@@ -114,17 +114,15 @@
114
115
/* Chat buttons (Send/Mic) */
116
#chat-buttons-wrapper { gap: var(--spacing-xs); padding-left: var(--spacing-xs); line-height: 0.5rem; display: -webkit-flex; display: flex; }
117
- .chat-button { border: none; border-radius: 50%; color: var(--color-background); cursor: pointer; font-size: var(--font-size-normal); height: 2.525rem; width: 2.525rem; margin: 0 0.18rem 0 0 var(--spacing-xs); display: -webkit-flex; display: flex; align-items: center; justify-content: center; flex-shrink: 0; flex-grow: 0; min-width: 2.525rem; -webkit-transition: all var(--transition-speed), transform 0.1s ease-in-out; transition: all var(--transition-speed), transform 0.1s ease-in-out; }
117
+ .chat-button { border: none; border-radius: 50%; color: var(--color-background); cursor: pointer; font-size: var(--font-size-normal); height: 2.525rem; width: 2.525rem; margin: 0 0.18rem 0 0 var(--spacing-xs); display: -webkit-flex; display: flex; align-items: center; justify-content: center; flex-shrink: 0; flex-grow: 0; min-width: 2.525rem; -webkit-transition: background-color var(--transition-speed), box-shadow 0.12s ease-in-out, filter 0.12s ease-in-out; transition: background-color var(--transition-speed), box-shadow 0.12s ease-in-out, filter 0.12s ease-in-out; }
118
#send-button { background-color: #4248f1; }
119
#send-button.send-queue { background-color: #e67e22; }
120
- #send-button:hover { -webkit-transform: scale(1.05); transform: scale(1.05); transform-origin: center; background-color: #353bc5; }
120
+ #send-button:hover { background-color: #353bc5; box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 6px 14px rgba(0,0,0,0.2); }
121
#send-button.send-queue:hover { background-color: #d35400; }
122
- #send-button:active { -webkit-transform: scale(1); transform: scale(1); transform-origin: center; background-color: #2b309c; }
122
+ #send-button:active { background-color: #2b309c; filter: brightness(0.96); }
123
.chat-button svg { width: 1.5rem; height: 1.5rem; }
124
#send-button .material-symbols-outlined { font-size: 1.5rem; }
125
126
- /* Microphone button */
127
- .chat-button.mic-inactive svg { /* Add specific styles if needed */ }
126
/* Responsive tweaks */
127
@media (max-width: 640px) {
128
#chat-input { min-height: 5.3rem; align-content: start; }
webui/components/chat/message-queue/message-queue.html
+4
-3
@@ -95,17 +95,18 @@
95
display: flex;
96
align-items: center;
97
opacity: 0.6;
98
- transition: all 0.1s ease-in-out;
98
+ transition: opacity 0.1s ease-in-out, color 0.1s ease-in-out,
99
+ box-shadow 0.1s ease-in-out;
100
}
101
102
.queue-header-btn:hover {
103
opacity: 1;
103
- transform: scale(1.1);
104
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent);
105
}
106
107
.queue-header-btn:active {
108
opacity: 0.5;
108
- transform: scale(0.95);
109
+ box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.06);
110
}
111
112
.queue-header-btn .material-symbols-outlined {
webui/components/messages/process-group/process-group.css
+2
-2
@@ -844,13 +844,13 @@
844
border-radius: 4px;
845
border: 1px solid rgba(255, 255, 255, 0.1);
846
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
847
- transition: transform 0.2s ease, box-shadow 0.2s ease;
847
+ transition: box-shadow 0.2s ease, filter 0.2s ease;
848
object-fit: contain;
849
}
850
851
.process-step-detail-scroll .screenshot-img:hover {
852
- transform: scale(1.02);
852
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
853
+ filter: brightness(1.02);
854
}
855
856
/* Light mode screenshot border */
webui/components/notifications/notification-modal.html
+1
-1
@@ -38,7 +38,7 @@
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">
41
+ <div class="notification-message" x-html="notification.message">
42
</div>
43
<div class="notification-timestamp"
44
x-text="$store.notificationStore?.formatTimestamp(notification.timestamp) || notification.timestamp">
webui/components/notifications/notification-store.js
+59
-7
@@ -21,7 +21,7 @@ const maxNotifications = 100;
21
const maxToasts = 5;
22
23
const model = {
24
- notifications: [],
24
+ notifications: Array(),
25
loading: false,
26
lastNotificationVersion: 0,
27
lastNotificationGuid: "",
@@ -29,7 +29,7 @@ const model = {
29
unreadPrioCount: 0,
30
31
// NEW: Toast stack management
32
- toastStack: [],
32
+ toastStack: Array(),
33
34
init() {
35
this.initialize();
@@ -70,11 +70,26 @@ const model = {
70
// adjust notification data before adding
71
this.adjustNotificationData(notification);
72
73
- const isNew = !this.notifications.find((n) => n.id === notification.id);
73
+ const existingNotificationIndex = this.notifications.findIndex(
74
+ (n) => n.id === notification.id
75
+ );
76
+ const existingNotification =
77
+ existingNotificationIndex >= 0
78
+ ? this.notifications[existingNotificationIndex]
79
+ : null;
80
+ const isNew = !existingNotification;
81
+ const shouldRetoast =
82
+ !!existingNotification &&
83
+ shouldToast &&
84
+ (existingNotification.timestamp !== notification.timestamp ||
85
+ existingNotification.title !== notification.title ||
86
+ existingNotification.message !== notification.message ||
87
+ existingNotification.detail !== notification.detail);
88
+
89
this.addOrUpdateNotification(notification);
90
76
- // Add new unread notifications to toast stack
77
- if (isNew && shouldToast) {
91
+ // Add new unread notifications to toast stack, and also re-toast updated unread notifications
92
+ if ((isNew && shouldToast) || shouldRetoast) {
93
this.addToToastStack(notification);
94
}
95
});
@@ -125,9 +140,30 @@ const model = {
140
}
141
142
// Set auto-dismiss timer
143
+ this.restartToastTimer(toast.toastId);
144
+ },
145
+
146
+ clearToastTimer(toastId) {
147
+ const toastIndex = this.toastStack.findIndex((t) => t.toastId === toastId);
148
+ if (toastIndex < 0) return;
149
+
150
+ const toast = this.toastStack[toastIndex];
151
+ if (toast.autoRemoveTimer) {
152
+ clearTimeout(toast.autoRemoveTimer);
153
+ toast.autoRemoveTimer = null;
154
+ }
155
+ },
156
+
157
+ restartToastTimer(toastId) {
158
+ const toastIndex = this.toastStack.findIndex((t) => t.toastId === toastId);
159
+ if (toastIndex < 0) return;
160
+
161
+ const toast = this.toastStack[toastIndex];
162
+
163
+ this.clearToastTimer(toastId);
164
toast.autoRemoveTimer = setTimeout(() => {
165
this.removeFromToastStack(toast.toastId);
130
- }, notification.display_time * 1000);
166
+ }, toast.display_time * 1000);
167
},
168
169
// NEW: Remove toast from stack
@@ -186,7 +222,21 @@ const model = {
222
},
223
224
// NEW: Handle toast click (opens modal)
189
- async handleToastClick(toastId) {
225
+ async handleToastClick(toastId, event) {
226
+ const target = event?.target;
227
+ const toast = this.toastStack.find((t) => t.toastId === toastId);
228
+ if (
229
+ target instanceof Element &&
230
+ target.closest(
231
+ 'button, a, input, select, textarea, summary, label, [role="button"], [data-toast-interactive]'
232
+ )
233
+ ) {
234
+ if (toast?.id) {
235
+ this.markAsRead(toast.id);
236
+ }
237
+ return;
238
+ }
239
+
240
await this.openModal();
241
// Modal opening will clear toast stack via markAllAsRead
242
},
@@ -587,6 +637,8 @@ const model = {
637
toastId: `toast-${notification.id}`,
638
addedAt: Date.now(),
639
autoRemoveTimer: null,
640
+ hoverTimer: null,
641
+ isHovered: false,
642
};
643
644
// Add to bottom of stack (newest at bottom)
webui/components/notifications/notification-toast-stack.html
+8
-5
@@ -15,7 +15,9 @@
15
<template x-for="(toast, index) in $store.notificationStore.toastStack" :key="toast.toastId">
16
<div class="toast-item"
17
:class="$store.notificationStore.getNotificationClass(toast.type)"
18
- @click="$store.notificationStore.handleToastClick(toast.toastId)"
18
+ @click="$store.notificationStore.handleToastClick(toast.toastId, $event)"
19
+ @mouseenter="$store.notificationStore.clearToastTimer(toast.toastId)"
20
+ @mouseleave="$store.notificationStore.restartToastTimer(toast.toastId)"
21
x-transition:enter="toast-enter"
22
x-transition:leave="toast-leave">
23
@@ -31,7 +33,7 @@
33
x-text="toast.title">
34
</div>
35
<div class="toast-message"
34
- x-text="toast.message">
36
+ x-html="toast.message">
37
</div>
38
<!-- <div class="toast-timestamp"
39
x-text="$store.notificationStore.formatTimestamp(toast.timestamp)">
@@ -77,7 +79,8 @@
79
border: 1px solid var(--color-border);
80
border-radius: 8px;
81
cursor: pointer;
80
- transition: all 0.2s ease;
82
+ transition: background-color 0.2s ease, border-color 0.2s ease,
83
+ box-shadow 0.2s ease, filter 0.2s ease;
84
/* backdrop-filter: blur(10px); */
85
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
86
position: relative;
@@ -87,8 +90,8 @@
90
}
91
92
.toast-item:hover {
90
- transform: translateY(-2px);
93
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
94
+ filter: brightness(1.02);
95
}
96
97
/* Toast Type Styling */
@@ -216,7 +219,7 @@
219
}
220
221
.toast-item:hover {
219
- transform: none;
222
+ filter: none;
223
}
224
}
225
</style>
webui/components/projects/project-selector.html
+16
@@ -57,6 +57,10 @@
57
</body>
58
59
<style>
60
+ .project-dropdown-container {
61
+ position: relative;
62
+ }
63
+
64
.project-dropdown-button {
65
display: inline-flex;
66
align-items: center;
@@ -64,5 +68,17 @@
68
padding-left: 0.5em;
69
padding-right: 0.5em;
70
}
71
+
72
+ /* Keep the menu flush with the trigger so the pointer does not cross a tiny hover gap. */
73
+ .project-dropdown-container .dropdown-menu {
74
+ margin-top: 0;
75
+ top: calc(100% - 1px);
76
+ }
77
+
78
+ /* Override shared dropdown opacity here so project items do not look muted while hovering edges. */
79
+ .project-dropdown-container .dropdown-item,
80
+ .project-dropdown-container .dropdown-item .material-symbols-outlined {
81
+ opacity: 1;
82
+ }
83
</style>
84
</html>
webui/components/settings/tunnel/tunnel-section.html
+5
-4
@@ -476,14 +476,15 @@
476
color: #0078d4;
477
text-decoration: none;
478
font-size: 0.85rem;
479
- transition: all 0.2s ease;
479
+ transition: background-color 0.2s ease, border-color 0.2s ease,
480
+ box-shadow 0.2s ease;
481
word-break: break-all;
482
}
483
484
.microsoft-login-link:hover {
485
background-color: rgba(0, 120, 212, 0.2);
486
border-color: rgba(0, 120, 212, 0.5);
486
- transform: translateY(-1px);
487
+ box-shadow: 0 4px 12px rgba(0, 120, 212, 0.15);
488
}
489
490
.microsoft-login-link .icon {
@@ -529,12 +530,12 @@
530
display: inline-flex;
531
align-items: center;
532
gap: 0.4rem;
532
- transition: all 0.2s ease;
533
+ transition: background-color 0.2s ease, box-shadow 0.2s ease,
534
+ filter 0.2s ease;
535
}
536
537
.btn-copy-code:hover {
538
background-color: #106ebe;
537
- transform: translateY(-1px);
539
box-shadow: 0 2px 8px rgba(0, 120, 212, 0.3);
540
}
541
webui/components/sidebar/top-section/quick-actions.html
+9
-6
@@ -140,7 +140,9 @@
140
padding: var(--spacing-xs);
141
flex: 1;
142
min-width: 0;
143
- transition: all var(--transition-speed), transform 0.1s ease-in-out;
143
+ transition: background-color var(--transition-speed),
144
+ border-color var(--transition-speed), box-shadow var(--transition-speed),
145
+ opacity var(--transition-speed);
146
}
147
148
.config-button .material-symbols-outlined {
@@ -171,13 +173,14 @@
173
.config-button:hover {
174
background-color: var(--color-background-hover);
175
opacity: 1;
176
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent);
177
z-index: 1;
178
position: relative;
179
}
180
181
.config-button:active {
182
opacity: 0.5;
180
- transform: scale(0.95);
183
+ box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.06);
184
}
185
186
/* Dropdown specific positioning - fixed to escape overflow:hidden */
@@ -222,11 +225,11 @@
225
align-items: center;
226
opacity: 0.6;
227
cursor: pointer;
225
- -webkit-transition: all 0.1s ease-in-out, left var(--transition-speed) ease-in-out;
226
- transition: all 0.1s ease-in-out, left var(--transition-speed) ease-in-out;
228
+ -webkit-transition: opacity 0.1s ease-in-out, color 0.1s ease-in-out, filter 0.1s ease-in-out, left var(--transition-speed) ease-in-out;
229
+ transition: opacity 0.1s ease-in-out, color 0.1s ease-in-out, filter 0.1s ease-in-out, left var(--transition-speed) ease-in-out;
230
}
228
- #newChat:hover { opacity: 0.85; transform: scale(1.04); }
229
- #newChat:active { opacity: 0.5; transform: scale(0.98); }
231
+ #newChat:hover { opacity: 0.85; filter: brightness(1.08); }
232
+ #newChat:active { opacity: 0.5; filter: brightness(0.96); }
233
#newChat .material-symbols-outlined {
234
color: var(--color-text);
235
font-size: 24px;
webui/css/buttons.css
+22
-9
@@ -9,7 +9,11 @@
9
color: var(--color-text);
10
font-size: 0.875rem;
11
font-family: "Rubik", Arial, Helvetica, sans-serif;
12
- transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
12
+ transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
13
+ border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
14
+ color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
15
+ box-shadow 0.18s cubic-bezier(0.4, 0, 0.2, 1),
16
+ filter 0.18s cubic-bezier(0.4, 0, 0.2, 1);
17
min-height: 2em; /* Standard height */
18
display: inline-flex;
19
align-items: center;
@@ -29,8 +33,9 @@
33
}
34
35
.button:hover {
32
- transform: scale(1.05);
36
filter: brightness(1.05);
37
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent),
38
+ 0 6px 14px rgba(0, 0, 0, 0.12);
39
}
40
41
.button.cancel:hover {
@@ -73,7 +78,8 @@
78
border: 1px solid var(--color-border);
79
border-radius: 4px;
80
cursor: pointer;
76
- transition: all 0.15s ease;
81
+ transition: background-color 0.15s ease, border-color 0.15s ease,
82
+ color 0.15s ease, box-shadow 0.15s ease;
83
}
84
85
.btn-action-header .material-symbols-outlined {
@@ -83,6 +89,7 @@
89
.btn-action-header:hover {
90
border-color: var(--color-primary);
91
color: var(--color-primary);
92
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
93
}
94
95
.btn-action-header.confirm:hover {
@@ -116,13 +123,13 @@
123
border: 1px solid var(--color-border);
124
padding: 0.25rem;
125
color: var(--color-text);
119
- opacity: 0.7;
126
display: inline-flex;
127
align-items: center;
128
justify-content: center;
129
border-radius: 4px;
130
cursor: pointer;
125
- transition: all 0.15s ease;
131
+ transition: background-color 0.15s ease, border-color 0.15s ease,
132
+ color 0.15s ease, box-shadow 0.15s ease;
133
}
134
135
.btn-action .material-symbols-outlined {
@@ -130,11 +137,11 @@
137
}
138
139
.btn-action:hover {
133
- opacity: 1;
140
background: var(--color-panel);
141
border-color: var(--color-primary);
142
color: var(--color-primary);
137
- transform: translateY(-1px);
143
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent),
144
+ 0 4px 10px rgba(0, 0, 0, 0.1);
145
}
146
147
.btn-action.delete:hover {
@@ -153,13 +160,15 @@
160
align-items: center;
161
border-radius: 4px;
162
cursor: pointer;
156
- transition: all 0.15s ease;
163
+ transition: background-color 0.15s ease, border-color 0.15s ease,
164
+ color 0.15s ease, box-shadow 0.15s ease;
165
}
166
167
.btn-icon:hover:not(:disabled) {
168
background: var(--color-panel);
169
border-color: var(--color-primary);
170
color: var(--color-text);
171
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
172
}
173
174
.btn-icon:disabled {
@@ -202,7 +211,10 @@
211
padding: 0.25rem;
212
width: 1.75rem;
213
height: 1.75rem;
205
- transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
214
+ transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
215
+ border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
216
+ color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
217
+ box-shadow 0.18s cubic-bezier(0.4, 0, 0.2, 1);
218
flex-shrink: 0;
219
}
220
@@ -214,6 +226,7 @@
226
.btn-icon-action:hover {
227
border-color: var(--color-primary);
228
background-color: var(--color-background-hover);
229
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
230
}
231
232
.btn-icon-action:active {
webui/css/messages.css
+1
-1
@@ -545,7 +545,7 @@
545
}
546
547
.message-agent-response .msg-content .message-markdown-image-wrap img:hover {
548
- transform: translateY(-2px);
548
+ filter: brightness(1.02);
549
}
550
551
.msg-content h1 {
webui/css/notification.css
+5
-3
@@ -12,7 +12,8 @@
12
border-radius: 6px;
13
color: var(--color-text);
14
cursor: pointer;
15
- transition: all 0.2s ease;
15
+ transition: background-color 0.2s ease, border-color 0.2s ease,
16
+ box-shadow 0.2s ease, opacity 0.2s ease;
17
font-size: 1rem;
18
width: 36px;
19
height: 36px;
@@ -25,7 +26,8 @@
26
.notification-toggle:hover {
27
background: rgba(255, 255, 255, 0.1);
28
border-color: rgba(255, 255, 255, 0.2);
28
- transform: scale(1.05);
29
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08),
30
+ 0 4px 12px rgba(0, 0, 0, 0.18);
31
}
32
33
.notification-toggle .notification-icon {
@@ -80,7 +82,7 @@
82
.notification-toggle.disabled:hover {
83
background: rgba(255, 255, 255, 0.05);
84
border-color: rgba(255, 255, 255, 0.1);
83
- transform: none;
85
+ box-shadow: none;
86
}
87
88
webui/css/settings.css
+3
-2
@@ -180,13 +180,14 @@ nav ul li a {
180
border-radius: 8px;
181
padding: 1rem;
182
width: 100%;
183
- transition: all 0.2s ease-in-out;
183
+ transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out,
184
+ box-shadow 0.2s ease-in-out, opacity 0.2s ease-in-out;
185
}
186
187
nav ul li a:hover {
187
- transform: translateY(-2px);
188
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
189
background-color: var(--color-secondary);
190
+ border-color: var(--color-primary);
191
}
192
193
nav ul li a img {
webui/css/speech.css
+3
-8
@@ -1,22 +1,17 @@
1
/* MIC BUTTON */
2
-#microphone-button {
3
-}
2
3
/* Only apply hover effects on devices that support hover */
4
@media (hover: hover) {
5
#microphone-button:hover {
6
background-color: #636363;
9
- transform: scale(1.05);
10
- -webkit-transform: scale(1.05);
11
- transform-origin: center;
7
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08),
8
+ 0 6px 14px rgba(0, 0, 0, 0.18);
9
}
10
}
11
12
#microphone-button:active {
13
background-color: #444444;
17
- transform: scale(1);
18
- -webkit-transform: scale(1);
19
- transform-origin: center;
14
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
15
}
16
17
#microphone-button.recording {
webui/index.css
+13
-13
@@ -491,7 +491,8 @@ h4 {
491
justify-content: center;
492
overflow: hidden;
493
cursor: pointer;
494
- transition: all 0.3s ease;
494
+ transition: border-color 0.3s ease, box-shadow 0.3s ease,
495
+ filter 0.3s ease;
496
animation: fadeIn 0.3s ease;
497
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
498
}
@@ -499,7 +500,7 @@ h4 {
500
.preview-item:hover {
501
border-color: var(--color-primary);
502
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
502
- transform: translateY(-2px);
503
+ filter: brightness(1.02);
504
}
505
506
.preview-item.image-preview img {
@@ -566,21 +567,19 @@ h4 {
567
display: flex;
568
align-items: center;
569
justify-content: center;
569
- transition: all 0.2s ease;
570
+ transition: background-color 0.2s ease, box-shadow 0.2s ease,
571
+ opacity 0.2s ease;
572
z-index: 2;
573
opacity: 0;
572
- transform: scale(0.8);
574
}
575
576
.device-pointer .preview-item:hover .remove-attachment,
577
.device-pointer .attachment-item:hover .remove-attachment {
578
opacity: 1;
578
- transform: scale(1);
579
}
580
581
.device-touch .remove-attachment {
582
opacity: 1 !important;
583
- transform: scale(1.25) !important;
583
/* font-size: 1.5em; */
584
top: 0.5em;
585
right: 0.5em;
@@ -588,15 +587,15 @@ h4 {
587
588
.remove-attachment:hover {
589
background-color: var(--color-accent-dark);
591
- transform: scale(1.1) !important;
590
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18), 0 6px 14px rgba(0, 0, 0, 0.24);
591
}
592
593
.device-touch .remove-attachment:active {
595
- transform: scale(1.1) !important;
594
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12) !important;
595
}
596
597
.remove-attachment:active {
599
- transform: scale(0.9) !important;
598
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12) !important;
599
}
600
601
.image-error {
@@ -685,7 +684,8 @@ h4 {
684
justify-content: center;
685
overflow: hidden;
686
cursor: pointer;
688
- transition: all 0.3s ease;
687
+ transition: border-color 0.3s ease, box-shadow 0.3s ease,
688
+ filter 0.3s ease;
689
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
690
border-radius: var(--border-radius);
691
}
@@ -693,7 +693,7 @@ h4 {
693
.attachment-item:hover {
694
border-color: var(--color-primary);
695
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
696
- transform: translateY(-2px);
696
+ filter: brightness(1.02);
697
}
698
699
.light-mode .attachment-item:hover {
@@ -844,11 +844,11 @@ h4 {
844
845
.remove-attachment:hover {
846
background-color: var(--color-accent);
847
- transform: scale(1.1);
847
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18), 0 6px 14px rgba(0, 0, 0, 0.24);
848
}
849
850
.remove-attachment:active {
851
- transform: scale(0.9);
851
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
852
}
853
854
/* Error handling */
webui/js/extensions.js
+36
-1
@@ -25,7 +25,7 @@ export const API_EXTENSION_EXCLUDED_ENDPOINTS = new Set([
25
"/api/load_webui_extensions",
26
]);
27
28
-export function invalidateCache() {
28
+export function clearCache() {
29
cache.clear(JS_CACHE_AREA);
30
cache.clear(HTML_CACHE_AREA);
31
}
@@ -115,6 +115,41 @@ export async function loadHtmlExtensions(roots = [document.documentElement]) {
115
}
116
}
117
118
+/**
119
+ * Reload and re-render all HTML extensions in the given DOM roots.
120
+ *
121
+ * @param {Element | Document | Array<Element | Document>} [roots]
122
+ * @returns {Promise<void>}
123
+ */
124
+export async function reloadHtmlExtensions(roots = [document.documentElement]) {
125
+ try {
126
+ /** @type {Array<Element | Document>} */
127
+ const rootElements = Array.isArray(roots) ? roots : [roots];
128
+
129
+ /** @type {Element[]} */
130
+ const extensions = rootElements.flatMap((root) =>
131
+ Array.from(root.querySelectorAll("x-extension")),
132
+ );
133
+
134
+ if (extensions.length === 0) return;
135
+
136
+ await Promise.all(
137
+ extensions.map(async (extension) => {
138
+ const path = extension.getAttribute("id");
139
+ if (!path) {
140
+ console.error("x-extension missing id attribute:", extension);
141
+ return;
142
+ }
143
+
144
+ extension.innerHTML = "";
145
+ await importHtmlExtensions(path, /** @type {HTMLElement} */ (extension));
146
+ }),
147
+ );
148
+ } catch (error) {
149
+ console.error("Error reloading HTML extensions:", error);
150
+ }
151
+}
152
+
153
// import all extensions for extension point via backend api
154
/**
155
* Import all HTML extensions for an extension point and inject them as `<x-component>` tags.