diff --git a/backend/open_webui/utils/notifications.py b/backend/open_webui/utils/notifications.py index 2884aeddb4..b11b91f3e2 100644 --- a/backend/open_webui/utils/notifications.py +++ b/backend/open_webui/utils/notifications.py @@ -42,10 +42,15 @@ def _normalize_target(target: dict[str, Any], existing: dict[str, Any] | None = validate_url(url) config['url'] = url - events = target.get('events', existing.get('events') or sorted(VALID_EVENTS)) - events = [str(event) for event in events if str(event) in VALID_EVENTS] - if not events: - raise ValueError('At least one notification event is required') + events = target['events'] if 'events' in target else existing.get('events', []) + if events is None: + events = [] + if not isinstance(events, list): + raise ValueError('events must be a list') + events = [str(event) for event in events] + unsupported = [event for event in events if event not in VALID_EVENTS] + if unsupported: + raise ValueError(f'unsupported notification event: {unsupported[0]}') delivery = str(target.get('delivery') or existing.get('delivery') or 'away') if delivery not in VALID_DELIVERY: @@ -83,7 +88,11 @@ async def _load_notifications(user_id: str) -> dict[str, Any]: targets = notifications.get('targets') if not isinstance(targets, list) or not targets: - legacy_url = str(settings.get('ui', {}).get('notifications', {}).get('webhook_url') or '').strip() + legacy_url = str( + notifications.get('webhook_url') + or settings.get('ui', {}).get('notifications', {}).get('webhook_url') + or '' + ).strip() if legacy_url: target = _normalize_target( { @@ -96,7 +105,7 @@ async def _load_notifications(user_id: str) -> dict[str, Any]: 'config': {'url': legacy_url}, } ) - notifications = {'targets': [target], 'default_target_id': DEFAULT_TARGET_ID} + notifications = {**notifications, 'targets': [target], 'default_target_id': DEFAULT_TARGET_ID} await Users.update_user_settings_by_id(user_id, {'notifications': notifications}) else: notifications['targets'] = [target for target in targets if isinstance(target, dict)] diff --git a/src/lib/components/chat/Settings/Notifications.svelte b/src/lib/components/chat/Settings/Notifications.svelte index 82ad4e2fbe..cf42ec7e43 100644 --- a/src/lib/components/chat/Settings/Notifications.svelte +++ b/src/lib/components/chat/Settings/Notifications.svelte @@ -4,11 +4,9 @@ import { toast } from 'svelte-sonner'; import { config, settings, user } from '$lib/stores'; + import Modal from '$lib/components/common/Modal.svelte'; import Switch from '$lib/components/common/Switch.svelte'; - import SettingsSelect from '$lib/components/common/SettingsSelect.svelte'; - import UserSettingField from './UserSettingField.svelte'; - import UserSettingRow from './UserSettingRow.svelte'; - import UserSettingSection from './UserSettingSection.svelte'; + import Plus from '$lib/components/icons/Plus.svelte'; import { createNotificationTarget, deleteNotificationTarget, @@ -26,40 +24,54 @@ let notificationEnabled = false; let notificationSound = true; - let notificationSoundAlways = false; let targets: NotificationTarget[] = []; let defaultTargetId: string | null = null; - let events: { event: string; label: string }[] = []; - let loading = false; - let editingTarget: NotificationTarget | null = null; - let targetName = 'Webhook'; - let targetUrl = ''; - let targetEnabled = true; - let targetDelivery: 'away' | 'always' = 'away'; - let targetEvents = ['chat.finished', 'chat.failed']; - - const inputClass = - 'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; - const actionButtonClass = - 'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white disabled:cursor-not-allowed disabled:opacity-50'; + let events: { event: string; label: string; description?: string }[] = [ + { + event: 'chat.finished', + label: 'Chat finished', + description: 'A chat run finished successfully.' + }, + { event: 'chat.failed', label: 'Chat failed', description: 'A chat run failed.' } + ]; + let loadingTargets = false; + let savingTarget = false; + let editingId: string | null = null; + let formOpen = false; + let form = { + id: '', + url: '', + enabled: true, + events: [] as string[], + delivery: 'away' as 'away' | 'always' + }; + let loadedTargets = false; $: canUseWebhooks = ($config?.features as any)?.enable_user_webhooks && ($user?.role === 'admin' || ($user?.permissions?.features?.webhooks ?? false)); - const setNotificationEnabled = async (enabled: boolean) => { - const permission = enabled ? await Notification.requestPermission() : 'granted'; + $: if (canUseWebhooks && !loadedTargets) { + void loadTargets(); + } - if (permission === 'granted') { - notificationEnabled = enabled; - saveSettings({ notificationEnabled }); + const toggleNotifications = async () => { + if (!notificationEnabled) { + const permission = + 'Notification' in window ? await Notification.requestPermission() : 'denied'; + if (permission === 'granted') { + notificationEnabled = true; + saveSettings({ notificationEnabled }); + } else { + toast.error( + $i18n.t( + 'Response notifications cannot be activated as the website permissions have been denied. Please visit your browser settings to grant the necessary access.' + ) + ); + } } else { notificationEnabled = false; - toast.error( - $i18n.t( - 'Response notifications cannot be activated as the website permissions have been denied. Please visit your browser settings to grant the necessary access.' - ) - ); + saveSettings({ notificationEnabled }); } }; @@ -68,75 +80,110 @@ return; } - loading = true; + loadingTargets = true; + loadedTargets = true; try { - events = await getNotificationEvents(localStorage.token); - const response = await getNotificationTargets(localStorage.token); - targets = response.targets ?? []; - defaultTargetId = response.default_target_id ?? null; + const [eventResult, targetResult] = await Promise.allSettled([ + getNotificationEvents(localStorage.token), + getNotificationTargets(localStorage.token) + ]); + + if (eventResult.status === 'fulfilled' && eventResult.value?.length) { + events = eventResult.value; + } + if (targetResult.status === 'fulfilled') { + targets = targetResult.value.targets ?? []; + defaultTargetId = targetResult.value.default_target_id ?? null; + } else { + toast.error(`${targetResult.reason}`); + } } catch (error) { toast.error(`${error}`); } finally { - loading = false; + loadingTargets = false; } }; - const resetForm = () => { - editingTarget = null; - targetName = 'Webhook'; - targetUrl = ''; - targetEnabled = true; - targetDelivery = 'away'; - targetEvents = ['chat.finished', 'chat.failed']; + const openNewTarget = () => { + editingId = null; + formOpen = true; + form = { + id: '', + url: '', + enabled: true, + events: [], + delivery: 'away' + }; }; - const editTarget = (target: NotificationTarget) => { - editingTarget = target; - targetName = target.name; - targetUrl = ''; - targetEnabled = target.enabled; - targetDelivery = target.delivery; - targetEvents = [...target.events]; + const openEditTarget = (target: NotificationTarget) => { + editingId = target.id; + formOpen = true; + form = { + id: target.id, + url: '', + enabled: target.enabled, + events: [...target.events], + delivery: target.delivery + }; + }; + + const toggleFormEvent = (event: string) => { + form.events = form.events.includes(event) + ? form.events.filter((item) => item !== event) + : [...form.events, event]; }; const saveTarget = async () => { + savingTarget = true; try { + const id = form.id.trim(); const payload: Partial = { + ...(id ? { id, name: id } : {}), type: 'webhook', - name: targetName, - enabled: targetEnabled, - events: targetEvents, - delivery: targetDelivery, - config: targetUrl ? { url: targetUrl } : {} + enabled: form.enabled, + events: form.events, + delivery: form.delivery, + ...(form.url.trim() ? { config: { url: form.url.trim() } } : {}) }; - if (editingTarget) { - await updateNotificationTarget(localStorage.token, editingTarget.id, payload); + if (editingId) { + await updateNotificationTarget(localStorage.token, editingId, payload); } else { await createNotificationTarget(localStorage.token, payload); } + await loadTargets(); + formOpen = false; toast.success($i18n.t('Settings saved successfully!')); - resetForm(); + } catch (error) { + toast.error(`${error}`); + } finally { + savingTarget = false; + } + }; + + const patchTarget = async (target: NotificationTarget, patch: Partial) => { + try { + await updateNotificationTarget(localStorage.token, target.id, patch); await loadTargets(); } catch (error) { toast.error(`${error}`); } }; - const toggleEvent = (event: string) => { - if (targetEvents.includes(event)) { - targetEvents = targetEvents.filter((item) => item !== event); - } else { - targetEvents = [...targetEvents, event]; + const sendTest = async (target: NotificationTarget) => { + try { + await testNotificationTarget(localStorage.token, target.id); + toast.success($i18n.t('Test notification sent.')); + } catch (error) { + toast.error(`${error}`); } }; onMount(async () => { notificationEnabled = $settings.notificationEnabled ?? false; notificationSound = $settings?.notificationSound ?? true; - notificationSoundAlways = $settings?.notificationSoundAlways ?? false; - await loadTargets(); }); @@ -146,24 +193,25 @@ {$i18n.t('Notifications')} - - +
+ +

+ {$i18n.t('Allow browser notifications for completed responses.')} +

- + + - {#if notificationSound} - - { - saveSettings({ notificationSoundAlways }); - }} - /> - - {/if} - + {#if canUseWebhooks} +
+ + {$i18n.t('Notification Targets')} + + +
- {#if canUseWebhooks} - - {#if loading} -
{$i18n.t('Loading...')}
- {:else if targets.length === 0} -
+ {#if loadingTargets} +

{$i18n.t('Loading...')}

+ {:else if !targets.length} +

{$i18n.t('No notification targets configured.')} -

+

{:else} -
+
{#each targets as target} -
-
-
-
- {target.name} - {#if target.id === defaultTargetId} - {$i18n.t('Default')} - {/if} -
-
- {target.config?.url} -
+ {@const alertLabels = events + .filter((event) => target.events.includes(event.event)) + .map((event) => event.label) + .join(', ')} +
+
+
+ + {target.id} + + + {$i18n.t('Webhook')} + + {#if target.id === defaultTargetId} + + {$i18n.t('Default')} + + {/if} +
+
+ {target.config?.url} +
+
+ {alertLabels || $i18n.t('No chat alerts')} + {#if target.events.length} + · {target.delivery === 'away' + ? $i18n.t('Only when away') + : $i18n.t('Always')} + {/if}
- - { - await updateNotificationTarget(localStorage.token, target.id, { - enabled: event.detail - }); - await loadTargets(); - }} - />
-
- {target.delivery === 'always' - ? $i18n.t('Always') - : $i18n.t('Only when away')} - {target.events.join(', ')} -
- -
+
sendTest(target)} > + {$i18n.t('Send Test')} + + {#if target.id !== defaultTargetId} + + {/if} openEditTarget(target)} > + {$i18n.t('Edit')} + - + {$i18n.t('Remove')} + +
+ +
+ patchTarget(target, { enabled: event.detail })} + />
{/each}
{/if} - - - - - - - - - - - - - - - - - - - - - - - -
- {#each events as item} - - {/each} -
-
- -
- {#if editingTarget} - - {/if} - -
-
- {/if} + {/if} +
+ + +
+

+ {editingId ? $i18n.t('Edit') : $i18n.t('Add Notification Target')} +

+ +
+ +
+ +
+ {$i18n.t('Target ID for notify')} +
+ + +
+ {$i18n.t('Webhook')} +
+ + +
+ {$i18n.t('Automatic Events')} +
+
+ {#each events as event} + + {/each} +
+ + {#if form.events.length} +
+ {$i18n.t('Automatic Delivery')} +
+
+ {#each ['away', 'always'] as mode} + + {/each} +
+ {/if} + +

+ {$i18n.t( + 'The notify tool always sends to an enabled target, regardless of automatic event settings.' + )} +

+ +
+ + +
+
+
+ + diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte index a23bdc6fd9..37df23fc7f 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -30,6 +30,7 @@ import WrenchAlt from '../icons/WrenchAlt.svelte'; import Face from '../icons/Face.svelte'; import AppNotification from '../icons/AppNotification.svelte'; + import AdjustmentsHorizontal from '../icons/AdjustmentsHorizontal.svelte'; import ArchiveBox from '../icons/ArchiveBox.svelte'; import ChevronLeft from '../icons/ChevronLeft.svelte'; import Keyboard from '../icons/Keyboard.svelte'; @@ -914,7 +915,7 @@ selectedTab = 'interface'; }} > - + {$i18n.t('Interface')} {:else if tabId === 'notifications'}