Files
open-webui/src/lib/components/chat/Settings/Notifications.svelte
T
2026-07-16 01:10:13 -04:00

461 lines
14 KiB
Svelte

<script lang="ts">
import { getContext, onMount } from 'svelte';
import type { Writable } from 'svelte/store';
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 Plus from '$lib/components/icons/Plus.svelte';
import {
createNotificationTarget,
deleteNotificationTarget,
getNotificationEvents,
getNotificationTargets,
setDefaultNotificationTarget,
testNotificationTarget,
updateNotificationTarget,
type NotificationTarget
} from '$lib/apis/notifications';
const i18n: Writable<any> = getContext('i18n');
export let saveSettings: Function;
let notificationEnabled = false;
let notificationSound = true;
let targets: NotificationTarget[] = [];
let defaultTargetId: string | null = null;
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));
$: if (canUseWebhooks && !loadedTargets) {
void loadTargets();
}
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;
saveSettings({ notificationEnabled });
}
};
const loadTargets = async () => {
if (!canUseWebhooks) {
return;
}
loadingTargets = true;
loadedTargets = true;
try {
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 {
loadingTargets = false;
}
};
const openNewTarget = () => {
editingId = null;
formOpen = true;
form = {
id: '',
url: '',
enabled: true,
events: [],
delivery: 'away'
};
};
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<NotificationTarget> = {
...(id ? { id, name: id } : {}),
type: 'webhook',
enabled: form.enabled,
events: form.events,
delivery: form.delivery,
...(form.url.trim() ? { config: { url: form.url.trim() } } : {})
};
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!'));
} catch (error) {
toast.error(`${error}`);
} finally {
savingTarget = false;
}
};
const patchTarget = async (target: NotificationTarget, patch: Partial<NotificationTarget>) => {
try {
await updateNotificationTarget(localStorage.token, target.id, patch);
await loadTargets();
} catch (error) {
toast.error(`${error}`);
}
};
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;
});
</script>
<div id="tab-notifications" class="flex h-full flex-col text-sm">
<div class="flex-1 min-h-0 w-full overflow-y-auto scrollbar-hover pr-1.5">
<h2 class="mb-4 text-sm font-medium text-gray-900 dark:text-white">
{$i18n.t('Notifications')}
</h2>
<div class="flex flex-col gap-2.5">
<label class="flex cursor-pointer items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Browser Notifications')}
</span>
<Switch
state={notificationEnabled}
ariaLabel={$i18n.t('Browser Notifications')}
on:change={toggleNotifications}
/>
</label>
<p class="-mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Allow browser notifications for completed responses.')}
</p>
<label class="flex cursor-pointer items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Notification Sound')}
</span>
<Switch
bind:state={notificationSound}
ariaLabel={$i18n.t('Notification Sound')}
on:change={() => {
saveSettings({ notificationSound });
}}
/>
</label>
{#if canUseWebhooks}
<div class="mt-3 flex items-center justify-between">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
{$i18n.t('Notification Targets')}
</span>
<button
class="flex h-6 w-6 items-center justify-center rounded-lg text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={openNewTarget}
title={$i18n.t('Add Notification Target')}
aria-label={$i18n.t('Add Notification Target')}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
{#if loadingTargets}
<p class="text-[0.6875rem] text-gray-400 dark:text-gray-600">{$i18n.t('Loading...')}</p>
{:else if !targets.length}
<p class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('No notification targets configured.')}
</p>
{:else}
<div class="flex flex-col">
{#each targets as target}
{@const alertLabels = events
.filter((event) => target.events.includes(event.event))
.map((event) => event.label)
.join(', ')}
<div class="notification-target-row">
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<span class="truncate text-[0.71875rem] text-gray-700 dark:text-gray-300">
{target.id}
</span>
<span class="shrink-0 text-[0.625rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Webhook')}
</span>
{#if target.id === defaultTargetId}
<span class="shrink-0 text-[0.625rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Default')}
</span>
{/if}
</div>
<div
class="truncate text-[0.625rem] leading-tight text-gray-400 dark:text-gray-600"
>
{target.config?.url}
</div>
<div
class="truncate text-[0.625rem] leading-tight text-gray-400 dark:text-gray-600"
>
{alertLabels || $i18n.t('No chat alerts')}
{#if target.events.length}
&middot; {target.delivery === 'away'
? $i18n.t('Only when away')
: $i18n.t('Always')}
{/if}
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
class="text-[0.625rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => sendTest(target)}
>
{$i18n.t('Send Test')}
</button>
{#if target.id !== defaultTargetId}
<button
class="text-[0.625rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={async () => {
await setDefaultNotificationTarget(localStorage.token, target.id);
await loadTargets();
}}
>
{$i18n.t('Make Default')}
</button>
{/if}
<button
class="text-[0.625rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => openEditTarget(target)}
>
{$i18n.t('Edit')}
</button>
<button
class="text-[0.625rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={async () => {
await deleteNotificationTarget(localStorage.token, target.id);
await loadTargets();
}}
>
{$i18n.t('Remove')}
</button>
</div>
<div class="flex w-9 shrink-0 justify-end">
<Switch
state={target.enabled}
ariaLabel={$i18n.t('Enabled')}
on:change={(event) => patchTarget(target, { enabled: event.detail })}
/>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
</div>
</div>
<Modal size="sm" bind:show={formOpen}>
<div class="p-4">
<h2 class="mb-3 text-sm font-medium text-gray-900 dark:text-white">
{editingId ? $i18n.t('Edit') : $i18n.t('Add Notification Target')}
</h2>
<div class="mb-2 flex gap-1">
<button
class="h-7 rounded-md bg-gray-200/60 px-2 text-xs text-gray-900 transition-colors dark:bg-white/10 dark:text-white"
type="button"
>
{$i18n.t('Webhook')}
</button>
</div>
<div class="text-[0.625rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Target ID for notify')}
</div>
<input
bind:value={form.id}
placeholder={$i18n.t('Target ID')}
autocomplete="off"
spellcheck="false"
class="block w-full bg-transparent py-0.5 font-mono text-[0.8125rem] text-gray-700 outline-none placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
/>
<div class="mt-2 text-[0.625rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Webhook')}
</div>
<input
type="url"
bind:value={form.url}
placeholder={editingId
? $i18n.t('Keep current webhook URL')
: 'https://hooks.slack.com/services/...'}
autocomplete="off"
spellcheck="false"
class="block w-full bg-transparent py-0.5 font-mono text-[0.8125rem] text-gray-700 outline-none placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
/>
<div class="mt-3 mb-1 text-[0.625rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Automatic Events')}
</div>
<div class="flex flex-wrap gap-1">
{#each events as event}
<button
class="h-6 rounded-md px-2 text-[0.6875rem] transition-colors {form.events.includes(
event.event
)
? 'bg-gray-200/60 text-gray-900 dark:bg-white/10 dark:text-white'
: 'text-gray-500 hover:text-gray-800 dark:hover:text-gray-300'}"
type="button"
on:click={() => toggleFormEvent(event.event)}
>
{event.label}
</button>
{/each}
</div>
{#if form.events.length}
<div class="mt-3 mb-1 text-[0.625rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Automatic Delivery')}
</div>
<div class="flex gap-1">
{#each ['away', 'always'] as mode}
<button
class="h-6 rounded-md px-2 text-[0.6875rem] transition-colors {form.delivery === mode
? 'bg-gray-200/60 text-gray-900 dark:bg-white/10 dark:text-white'
: 'text-gray-500 hover:text-gray-800 dark:hover:text-gray-300'}"
type="button"
on:click={() => (form.delivery = mode as 'away' | 'always')}
>
{mode === 'away' ? $i18n.t('Only when away') : $i18n.t('Always')}
</button>
{/each}
</div>
{/if}
<p class="mt-3 text-[0.625rem] text-gray-400 dark:text-gray-600">
{$i18n.t(
'The notify tool always sends to an enabled target, regardless of automatic event settings.'
)}
</p>
<div class="mt-4 flex justify-end gap-3">
<button
class="text-[0.8125rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => (formOpen = false)}
>
{$i18n.t('Cancel')}
</button>
<button
class="text-[0.8125rem] text-gray-700 transition-colors duration-100 hover:text-gray-900 disabled:opacity-30 dark:text-gray-300 dark:hover:text-white"
type="button"
on:click={saveTarget}
disabled={savingTarget || (!editingId && !form.url.trim())}
>
{savingTarget ? $i18n.t('Saving...') : $i18n.t('Save')}
</button>
</div>
</div>
</Modal>
<style>
.notification-target-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 0.25rem;
border-bottom: 1px solid rgb(229 231 235 / 0.7);
}
.notification-target-row:last-child {
border-bottom: none;
}
:global(.dark) .notification-target-row {
border-bottom-color: rgb(255 255 255 / 0.06);
}
</style>