diff --git a/python/api/alert_test.py b/python/api/alert_test.py new file mode 100644 index 0000000000..2959252939 --- /dev/null +++ b/python/api/alert_test.py @@ -0,0 +1,26 @@ +from python.helpers.api import ApiHandler, Request, Response +from agent import AgentContext +from python.helpers.alert import emit_alert, AlertType +from typing import cast + + +class AlertTest(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + alert_type = input.get("alert_type", "") + if alert_type not in ("task_complete", "input_needed", "subagent_complete"): + return {"success": False, "error": "Invalid alert_type"} + + nm = AgentContext.get_notification_manager() + start = len(nm.updates) + + # Emit as an alert.* notification, respecting current alert settings. + emit_alert(cast(AlertType, alert_type)) + + return { + "success": True, + "notifications": nm.output(start=start), + "notifications_guid": nm.guid, + "notifications_version": len(nm.updates), + } + + diff --git a/python/extensions/monologue_end/_91_alert_subagent_complete.py b/python/extensions/monologue_end/_91_alert_subagent_complete.py new file mode 100644 index 0000000000..82427773c9 --- /dev/null +++ b/python/extensions/monologue_end/_91_alert_subagent_complete.py @@ -0,0 +1,18 @@ +from python.helpers.alert import emit_alert +from python.helpers.extension import Extension +from agent import AgentContextType, LoopData + + +class AlertSubagentComplete(Extension): + async def execute(self, loop_data: LoopData = LoopData(), **kwargs): + # Only subagents should emit this alert + if self.agent.number <= 0: + return + + # Never alert for background contexts + if self.agent.context and self.agent.context.type == AgentContextType.BACKGROUND: + return + + emit_alert("subagent_complete") + + diff --git a/python/extensions/tool_execute_after/_80_alert_task_complete.py b/python/extensions/tool_execute_after/_80_alert_task_complete.py new file mode 100644 index 0000000000..c2207818f4 --- /dev/null +++ b/python/extensions/tool_execute_after/_80_alert_task_complete.py @@ -0,0 +1,37 @@ +from python.helpers.alert import emit_alert +from python.helpers.extension import Extension +from python.helpers.task_scheduler import TaskScheduler +from python.helpers.tool import Response +from agent import AgentContextType + + +class AlertTaskComplete(Extension): + async def execute( + self, + response: Response | None = None, + tool_name: str | None = None, + **kwargs, + ): + # Only main agent should emit alerts + if self.agent.number != 0: + return + + # Never alert for background contexts + if self.agent.context and self.agent.context.type == AgentContextType.BACKGROUND: + return + + # Only when the agent finishes via the response tool + if tool_name != "response": + return + if not response or not getattr(response, "break_loop", False): + return + + # Only for scheduler task contexts + scheduler = TaskScheduler.get() + task = scheduler.get_task_by_uuid(self.agent.context.id) + if not task: + return + + emit_alert("task_complete") + + diff --git a/python/extensions/tool_execute_after/_81_alert_input_needed.py b/python/extensions/tool_execute_after/_81_alert_input_needed.py new file mode 100644 index 0000000000..04f020fd96 --- /dev/null +++ b/python/extensions/tool_execute_after/_81_alert_input_needed.py @@ -0,0 +1,37 @@ +from python.helpers.alert import emit_alert +from python.helpers.extension import Extension +from python.helpers.task_scheduler import TaskScheduler +from python.helpers.tool import Response +from agent import AgentContextType + + +class AlertInputNeeded(Extension): + async def execute( + self, + response: Response | None = None, + tool_name: str | None = None, + **kwargs, + ): + # Only main agent should emit alerts + if self.agent.number != 0: + return + + # Never alert for background contexts + if self.agent.context and self.agent.context.type == AgentContextType.BACKGROUND: + return + + # Only when the agent finishes via the response tool (end of a chat turn) + if tool_name != "response": + return + if not response or not getattr(response, "break_loop", False): + return + + # Exclude scheduler task contexts (those are handled by task-complete alert) + scheduler = TaskScheduler.get() + task = scheduler.get_task_by_uuid(self.agent.context.id) + if task: + return + + emit_alert("input_needed") + + diff --git a/python/helpers/alert.py b/python/helpers/alert.py new file mode 100644 index 0000000000..b07e67d04c --- /dev/null +++ b/python/helpers/alert.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import Literal + +from python.helpers import settings +from python.helpers.notification import ( + NotificationManager, + NotificationPriority, + NotificationType, +) + + +AlertType = Literal["task_complete", "input_needed", "subagent_complete"] + + +def _should_emit_alert(sett: settings.Settings, alert_type: AlertType) -> bool: + if not sett.get("alert_enabled", False): + return False + + if alert_type == "task_complete": + return bool(sett.get("alert_on_task_complete", False)) + if alert_type == "input_needed": + return bool(sett.get("alert_on_user_input_needed", False)) + if alert_type == "subagent_complete": + return bool(sett.get("alert_on_subagent_complete", False)) + + return False + + +def _get_default_message(alert_type: AlertType) -> str: + if alert_type == "task_complete": + return "Task completed" + if alert_type == "input_needed": + return "Waiting for your input" + if alert_type == "subagent_complete": + return "Subordinate agent completed" + return "Alert" + + +def _get_message(sett: settings.Settings, alert_type: AlertType) -> str: + if alert_type == "task_complete": + return str(sett.get("alert_tts_message_task_complete") or "").strip() + if alert_type == "input_needed": + return str(sett.get("alert_tts_message_input_needed") or "").strip() + if alert_type == "subagent_complete": + return str(sett.get("alert_tts_message_subagent_complete") or "").strip() + return "" + + +def _get_title(alert_type: AlertType) -> str: + if alert_type == "task_complete": + return "Task complete" + if alert_type == "input_needed": + return "Waiting for input" + if alert_type == "subagent_complete": + return "Subagent complete" + return "Alert" + + +def _get_notification_type(alert_type: AlertType) -> NotificationType: + if alert_type == "task_complete": + return NotificationType.SUCCESS + if alert_type == "input_needed": + return NotificationType.INFO + if alert_type == "subagent_complete": + return NotificationType.SUCCESS + return NotificationType.INFO + + +def emit_alert( + alert_type: AlertType, + *, + force: bool = False, + message_override: str | None = None, + display_time: int = 5, +) -> None: + """ + Emit an alert notification (no audio playback server-side). + + WebUI will observe notifications via /poll and play sound/tts based on its settings. + """ + sett = settings.get_settings() + if not force and not _should_emit_alert(sett, alert_type): + return + + group = f"alert.{alert_type}" + message = (message_override or _get_message(sett, alert_type)).strip() + if not message: + message = _get_default_message(alert_type) + + NotificationManager.send_notification( + _get_notification_type(alert_type), + NotificationPriority.NORMAL, + message=message, + title=_get_title(alert_type), + detail="", + display_time=display_time, + group=group, + ) + + diff --git a/python/helpers/settings.py b/python/helpers/settings.py index f09124bd70..1195f74e58 100644 --- a/python/helpers/settings.py +++ b/python/helpers/settings.py @@ -132,6 +132,19 @@ class Settings(TypedDict): tts_kokoro: bool + # Alerts (WebUI) + alert_enabled: bool + alert_on_task_complete: bool + alert_on_user_input_needed: bool + alert_on_subagent_complete: bool + alert_sound_type: Literal["chime", "beep", "custom"] + alert_custom_sound_path: str + alert_tts_enabled: bool + alert_tts_use_kokoro: bool + alert_tts_message_task_complete: str + alert_tts_message_input_needed: str + alert_tts_message_subagent_complete: str + mcp_servers: str mcp_client_init_timeout: int mcp_client_tool_timeout: int @@ -1092,6 +1105,163 @@ def convert_out(settings: Settings) -> SettingsOutput: "tab": "agent", } + # Alerts section + alerts_fields: list[SettingsField] = [] + + alerts_fields.append( + { + "id": "alert_enabled", + "title": "Enable alerts", + "description": "Enable audible alerts that play in the Web UI browser when Agent Zero needs your attention.", + "type": "switch", + "value": settings["alert_enabled"], + } + ) + + alerts_fields.append( + { + "id": "alert_on_user_input_needed", + "title": "Alert when waiting for your input", + "description": "Play an alert after a normal chat response completes (i.e. when it’s your turn to respond).", + "type": "switch", + "value": settings["alert_on_user_input_needed"], + } + ) + + alerts_fields.append( + { + "id": "alert_on_task_complete", + "title": "Alert when a scheduler task completes", + "description": "Play an alert when a Task Scheduler task finishes.", + "type": "switch", + "value": settings["alert_on_task_complete"], + } + ) + + alerts_fields.append( + { + "id": "alert_on_subagent_complete", + "title": "Alert when a subagent completes", + "description": "Play an alert when a subordinate agent finishes.", + "type": "switch", + "value": settings["alert_on_subagent_complete"], + } + ) + + alerts_fields.append( + { + "id": "alert_sound_type", + "title": "Alert sound", + "description": "Sound to play for alerts. Custom uses a browser-fetchable URL/path.", + "type": "select", + "value": settings["alert_sound_type"], + "options": [ + {"value": "chime", "label": "Chime"}, + {"value": "beep", "label": "Beep"}, + {"value": "custom", "label": "Custom (URL/path)"}, + ], + } + ) + + alerts_fields.append( + { + "id": "alert_custom_sound_path", + "title": "Custom sound URL/path", + "description": "Browser-accessible URL/path to an audio file (e.g. /public/alert.wav). Used when Alert sound is set to Custom.", + "type": "text", + "value": settings["alert_custom_sound_path"], + } + ) + + alerts_fields.append( + { + "id": "alert_tts_enabled", + "title": "Speak alert message", + "description": "Speak the alert message after the sound (uses browser TTS or Kokoro depending on Speech settings).", + "type": "switch", + "value": settings["alert_tts_enabled"], + } + ) + + alerts_fields.append( + { + "id": "alert_tts_use_kokoro", + "title": "Use Kokoro for alerts", + "description": "Force server-side Kokoro TTS for alert speech. When disabled, alerts follow Speech settings (browser TTS or Kokoro).", + "type": "switch", + "value": settings["alert_tts_use_kokoro"], + } + ) + + alerts_fields.append( + { + "id": "alert_tts_message_input_needed", + "title": "Message: waiting for input", + "description": "Text shown (toast) and optionally spoken when the agent is waiting for your input.", + "type": "text", + "value": settings["alert_tts_message_input_needed"], + } + ) + + alerts_fields.append( + { + "id": "alert_tts_message_task_complete", + "title": "Message: task complete", + "description": "Text shown (toast) and optionally spoken when a scheduler task completes.", + "type": "text", + "value": settings["alert_tts_message_task_complete"], + } + ) + + alerts_fields.append( + { + "id": "alert_tts_message_subagent_complete", + "title": "Message: subagent complete", + "description": "Text shown (toast) and optionally spoken when a subagent completes.", + "type": "text", + "value": settings["alert_tts_message_subagent_complete"], + } + ) + + # Test buttons + alerts_fields.append( + { + "id": "alert_test_input_needed", + "title": "Test: waiting for input", + "description": "Play a test “waiting for input” alert in this browser.", + "type": "button", + "value": "Test", + } + ) + + alerts_fields.append( + { + "id": "alert_test_task_complete", + "title": "Test: task complete", + "description": "Play a test “task complete” alert in this browser.", + "type": "button", + "value": "Test", + } + ) + + alerts_fields.append( + { + "id": "alert_test_subagent_complete", + "title": "Test: subagent complete", + "description": "Play a test “subagent complete” alert in this browser.", + "type": "button", + "value": "Test", + } + ) + + alerts_section: SettingsSection = { + "id": "alerts", + "title": "Alerts", + "description": "Audible alerts that play in the Web UI browser when Agent Zero needs your attention.", + "fields": alerts_fields, + "tab": "agent", + } + # MCP section mcp_client_fields: list[SettingsField] = [] @@ -1319,6 +1489,7 @@ def convert_out(settings: Settings) -> SettingsOutput: embed_model_section, memory_section, speech_section, + alerts_section, api_keys_section, litellm_section, secrets_section, @@ -1558,6 +1729,23 @@ def get_default_settings() -> Settings: stt_silence_duration=get_default_value("stt_silence_duration", 1000), stt_waiting_timeout=get_default_value("stt_waiting_timeout", 2000), tts_kokoro=get_default_value("tts_kokoro", True), + alert_enabled=get_default_value("alert_enabled", True), + alert_on_task_complete=get_default_value("alert_on_task_complete", True), + alert_on_user_input_needed=get_default_value("alert_on_user_input_needed", True), + alert_on_subagent_complete=get_default_value("alert_on_subagent_complete", True), + alert_sound_type=get_default_value("alert_sound_type", "chime"), + alert_tts_enabled=get_default_value("alert_tts_enabled", True), + alert_tts_use_kokoro=get_default_value("alert_tts_use_kokoro", False), + alert_tts_message_task_complete=get_default_value( + "alert_tts_message_task_complete", "Task completed" + ), + alert_tts_message_input_needed=get_default_value( + "alert_tts_message_input_needed", "Waiting for your input" + ), + alert_tts_message_subagent_complete=get_default_value( + "alert_tts_message_subagent_complete", "Subordinate agent completed" + ), + alert_custom_sound_path=get_default_value("alert_custom_sound_path", ""), mcp_servers=get_default_value("mcp_servers", '{\n "mcpServers": {}\n}'), mcp_client_init_timeout=get_default_value("mcp_client_init_timeout", 10), mcp_client_tool_timeout=get_default_value("mcp_client_tool_timeout", 120), diff --git a/webui/components/alerts/alert-store.js b/webui/components/alerts/alert-store.js new file mode 100644 index 0000000000..1a8f53b1cc --- /dev/null +++ b/webui/components/alerts/alert-store.js @@ -0,0 +1,273 @@ +import { createStore } from "/js/AlpineStore.js"; +import { fetchApi } from "/js/api.js"; +import * as shortcuts from "/js/shortcuts.js"; +import { store as speechStore } from "/components/chat/speech/speech-store.js"; + +const model = { + // Guards + _initialized: false, + _settingsLoaded: false, + _settingsLoadPromise: null, + + // Settings (defaults match backend defaults) + alert_enabled: true, + alert_on_task_complete: true, + alert_on_user_input_needed: true, + alert_on_subagent_complete: true, + alert_sound_type: "chime", // chime | beep | custom + alert_custom_sound_path: "", + alert_tts_enabled: true, + alert_tts_use_kokoro: false, + alert_tts_message_task_complete: "Task completed", + alert_tts_message_input_needed: "Waiting for your input", + alert_tts_message_subagent_complete: "Subordinate agent completed", + + // Audio state + userHasInteracted: false, + audioContext: null, + audioEl: null, + + init() { + if (this._initialized) return; + this._initialized = true; + + this.setupUserInteractionHandling(); + // Fire-and-forget settings load; handleAlertNotification will await if needed + this.loadSettings().catch((e) => + console.error("[AlertStore] Failed to load settings:", e) + ); + }, + + async loadSettings() { + try { + const response = await fetchApi("/settings_get", { method: "POST" }); + const data = await response.json(); + const alertsSection = data?.settings?.sections?.find( + (s) => s?.id === "alerts" || s?.title === "Alerts" + ); + + if (alertsSection?.fields?.length) { + alertsSection.fields.forEach((field) => { + if (Object.prototype.hasOwnProperty.call(this, field.id)) { + this[field.id] = field.value; + } + }); + } + + this._settingsLoaded = true; + } catch (error) { + // Keep defaults; do not throw to avoid breaking notification flow + this._settingsLoaded = true; + console.error("[AlertStore] Failed to load alert settings:", error); + } + }, + + setupUserInteractionHandling() { + const enableAudio = () => { + if (this.userHasInteracted) return; + this.userHasInteracted = true; + + // Try to create/resume AudioContext (may still be blocked until gesture) + try { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (Ctx && !this.audioContext) this.audioContext = new Ctx(); + if (this.audioContext?.resume) this.audioContext.resume(); + } catch (_e) { + // ignore + } + }; + + const events = ["click", "touchstart", "keydown", "mousedown"]; + events.forEach((event) => { + document.addEventListener(event, enableAudio, { once: true, passive: true }); + }); + }, + + // Called by notificationStore.updateFromPoll (must not throw or return unhandled promise) + handleAlertNotification(notification) { + void this._handleAlertNotification(notification).catch((e) => + console.error("[AlertStore] Alert handling failed:", e) + ); + }, + + async _handleAlertNotification(notification) { + if (!notification) return; + const group = String(notification.group || ""); + if (!group.startsWith("alert.")) return; + + if (!this._initialized) this.init(); + await this._ensureSettingsLoaded(); + + if (!this.alert_enabled) return; + + const kind = group.slice("alert.".length); // task_complete | input_needed | subagent_complete + if (kind === "task_complete" && !this.alert_on_task_complete) return; + if (kind === "input_needed" && !this.alert_on_user_input_needed) return; + if (kind === "subagent_complete" && !this.alert_on_subagent_complete) return; + + // Play sound first + await this._playAlertSound(); + + // Optional speech (uses Speech store settings: browser TTS vs Kokoro) + if (this.alert_tts_enabled) { + const msg = String(notification.message || "").trim(); + if (msg) { + try { + // Speech store is not globally auto-initialized; init it on demand. + if (typeof speechStore.init === "function") { + await speechStore.init(); + } + if ( + this.alert_tts_use_kokoro && + typeof speechStore.speakWithKokoro === "function" + ) { + try { + await speechStore.speakWithKokoro(msg, false); + } catch (e) { + // Fallback to default speech path if Kokoro fails + if (typeof speechStore.speak === "function") { + await speechStore.speak(msg); + } + } + } else if (typeof speechStore.speak === "function") { + await speechStore.speak(msg); + } + } catch (e) { + console.error("[AlertStore] Failed to speak alert message:", e); + } + } + } + }, + + async _ensureSettingsLoaded() { + if (this._settingsLoaded) return; + if (this._settingsLoadPromise) return await this._settingsLoadPromise; + this._settingsLoadPromise = this.loadSettings(); + return await this._settingsLoadPromise; + }, + + async _getAudioContext() { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return null; + if (!this.audioContext) this.audioContext = new Ctx(); + try { + if (this.audioContext.state === "suspended" && this.audioContext.resume) { + await this.audioContext.resume(); + } + } catch (_e) { + // ignore + } + return this.audioContext; + }, + + async _playAlertSound() { + const soundType = String(this.alert_sound_type || "chime"); + + // Try to play even if we haven't observed a user interaction yet; if blocked, prompt. + try { + if (soundType === "custom") { + const url = String(this.alert_custom_sound_path || "").trim(); + if (url) return await this._playAudioUrl(url); + // fall back to chime if custom missing + return await this._playChime(); + } + + if (soundType === "beep") return await this._playBeep(); + return await this._playChime(); + } catch (e) { + if (e?.name === "NotAllowedError") { + this._showAudioPermissionPrompt(); + this.userHasInteracted = false; + return; + } + console.error("[AlertStore] Sound playback failed:", e); + } + }, + + _showAudioPermissionPrompt() { + shortcuts.frontendNotification({ + type: "info", + title: "Enable audio alerts", + message: "Click anywhere on the page to enable audio playback for alerts.", + displayTime: 6, + group: "audio-alerts-permission", + priority: shortcuts.NotificationPriority.NORMAL, + frontendOnly: true, + }); + }, + + async _playBeep() { + return await this._playToneSequence([{ freq: 880, dur: 0.12 }]); + }, + + async _playChime() { + return await this._playToneSequence([ + { freq: 880, dur: 0.09 }, + { freq: 660, dur: 0.13 }, + ]); + }, + + async _playToneSequence(sequence) { + const ctx = await this._getAudioContext(); + if (!ctx) return; + + // If we haven't unlocked audio yet, prompt (but still attempt to play). + if (!this.userHasInteracted) { + this._showAudioPermissionPrompt(); + } + + const startAt = ctx.currentTime + 0.02; + let t = startAt; + + for (const tone of sequence) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.type = "sine"; + osc.frequency.setValueAtTime(tone.freq, t); + + gain.gain.setValueAtTime(0.0001, t); + gain.gain.exponentialRampToValueAtTime(0.12, t + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, t + tone.dur); + + osc.connect(gain); + gain.connect(ctx.destination); + + osc.start(t); + osc.stop(t + tone.dur + 0.01); + + t += tone.dur + 0.04; + } + + const totalMs = Math.max(0, Math.round((t - startAt) * 1000)); + await new Promise((resolve) => setTimeout(resolve, totalMs)); + }, + + async _playAudioUrl(url) { + return await new Promise((resolve, reject) => { + const audio = this.audioEl ? this.audioEl : (this.audioEl = new Audio()); + + audio.pause(); + audio.currentTime = 0; + + audio.onended = () => resolve(); + audio.onerror = (e) => reject(e); + + audio.src = url; + audio.play().catch((error) => reject(error)); + }); + }, +}; + +export const store = createStore("alertStore", model); + +// Reload on Settings save +document.addEventListener("settings-updated", () => store.loadSettings()); + +// Ensure initialization after Alpine mounts (Agent Zero lifecycle convention) +document.addEventListener("alpine:init", () => { + const s = Alpine.store("alertStore"); + if (s && typeof s.init === "function") s.init(); +}); + + diff --git a/webui/components/notifications/notification-store.js b/webui/components/notifications/notification-store.js index dbe96a929a..1bfcaf651e 100644 --- a/webui/components/notifications/notification-store.js +++ b/webui/components/notifications/notification-store.js @@ -2,6 +2,19 @@ import { createStore } from "/js/AlpineStore.js"; import * as API from "/js/api.js"; import { openModal } from "/js/modals.js"; +// Lazy-load alert store to avoid circular imports (notificationStore is a dependency of shortcuts/speech) +let _alertStorePromise = null; +function getAlertStore() { + if (_alertStorePromise) return _alertStorePromise; + _alertStorePromise = import("/components/alerts/alert-store.js") + .then((m) => m.store) + .catch((e) => { + console.error("Failed to load alert store:", e); + return null; + }); + return _alertStorePromise; +} + export const NotificationType = { INFO: "info", SUCCESS: "success", @@ -76,6 +89,19 @@ const model = { // Add new unread notifications to toast stack if (isNew && shouldToast) { this.addToToastStack(notification); + + // Trigger audible alerts for alert.* notifications (WebUI playback) + const group = String(notification.group || ""); + if (group.startsWith("alert.")) { + getAlertStore().then((alertStore) => { + if ( + alertStore && + typeof alertStore.handleAlertNotification === "function" + ) { + alertStore.handleAlertNotification(notification); + } + }); + } } }); } diff --git a/webui/js/settings.js b/webui/js/settings.js index 059d645410..db59ef0236 100644 --- a/webui/js/settings.js +++ b/webui/js/settings.js @@ -283,6 +283,33 @@ const settingsModalProxy = { openModal("settings/external/api-examples.html"); } else if (field.id === "memory_dashboard") { openModal("settings/memory/memory-dashboard.html"); + } else if ( + field.id === "alert_test_input_needed" || + field.id === "alert_test_task_complete" || + field.id === "alert_test_subagent_complete" + ) { + const typeMap = { + alert_test_input_needed: "input_needed", + alert_test_task_complete: "task_complete", + alert_test_subagent_complete: "subagent_complete", + }; + + try { + const alert_type = typeMap[field.id]; + const resp = await sendJsonData("/alert_test", { alert_type }); + + // Feed poll-shaped response directly into notification store for instant toast + audio + const notifStore = + window.Alpine && window.Alpine.store + ? window.Alpine.store("notificationStore") + : null; + if (notifStore && typeof notifStore.updateFromPoll === "function") { + notifStore.updateFromPoll(resp); + } + } catch (e) { + if (window.toastFetchError) window.toastFetchError("Error testing alert", e); + else console.error("Error testing alert", e); + } } } }; diff --git a/webui/public/alerts.svg b/webui/public/alerts.svg new file mode 100644 index 0000000000..4e52359600 --- /dev/null +++ b/webui/public/alerts.svg @@ -0,0 +1,4 @@ + + + +