This commit is contained in:
Timothy Jaeryang Baek
2026-07-16 00:58:34 -04:00
parent 771a024b40
commit c55e373b99
14 changed files with 921 additions and 161 deletions
+72
View File
@@ -0,0 +1,72 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type NotificationTarget = {
id: string;
type: 'webhook';
name: string;
enabled: boolean;
events: string[];
delivery: 'away' | 'always';
config: {
url?: string;
};
created_at?: number;
updated_at?: number;
};
const jsonRequest = async (url: string, token: string, method = 'GET', body?: object) => {
let error = null;
const res = await fetch(url, {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
...(body ? { body: JSON.stringify(body) } : {})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getNotificationEvents = async (token: string) =>
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/events`, token);
export const getNotificationTargets = async (
token: string
): Promise<{ targets: NotificationTarget[]; default_target_id: string | null }> =>
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets`, token);
export const createNotificationTarget = async (
token: string,
target: Partial<NotificationTarget>
) => jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets`, token, 'POST', target);
export const updateNotificationTarget = async (
token: string,
targetId: string,
target: Partial<NotificationTarget>
) => jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}`, token, 'PUT', target);
export const deleteNotificationTarget = async (token: string, targetId: string) =>
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}`, token, 'DELETE');
export const setDefaultNotificationTarget = async (token: string, targetId: string) =>
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}/default`, token, 'PUT');
export const testNotificationTarget = async (token: string, targetId: string) =>
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}/test`, token, 'POST');
@@ -2,7 +2,7 @@
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { user, config, settings } from '$lib/stores';
import { user, config } from '$lib/stores';
import { updateUserProfile, createAPIKey, getAPIKey, getSessionUser } from '$lib/apis/auths';
import { WEBUI_BASE_URL } from '$lib/constants';
@@ -23,7 +23,6 @@
const i18n = getContext('i18n');
export let saveHandler: Function;
export let saveSettings: Function;
let profileImageUrl = '';
let name = '';
@@ -33,7 +32,6 @@
let gender = '';
let dateOfBirth = '';
let webhookUrl = '';
let showAPIKeys = false;
let JWTTokenCopied = false;
@@ -55,15 +53,6 @@
}
}
if (webhookUrl !== $settings?.notifications?.webhook_url) {
saveSettings({
notifications: {
...$settings.notifications,
webhook_url: webhookUrl
}
});
}
const updatedUser = await updateUserProfile(localStorage.token, {
name: name,
profile_image_url: profileImageUrl,
@@ -113,8 +102,6 @@
dateOfBirth = user?.date_of_birth ?? '';
}
webhookUrl = $settings?.notifications?.webhook_url ?? '';
// Only fetch API key if the feature is enabled and user has permission
if (
user &&
@@ -219,24 +206,6 @@
</UserSettingField>
</UserSettingSection>
{#if $config?.features?.enable_user_webhooks && ($user?.role === 'admin' || ($user?.permissions?.features?.webhooks ?? false))}
<UserSettingSection title={$i18n.t('Notifications')}>
<UserSettingField
label={$i18n.t('Notification Webhook')}
description={$i18n.t('Send account notifications to this webhook URL.')}
>
<input
class={inputClass}
type="url"
placeholder={$i18n.t('Enter your webhook URL')}
aria-label={$i18n.t('Notification Webhook')}
bind:value={webhookUrl}
required
/>
</UserSettingField>
</UserSettingSection>
{/if}
{#if $config?.features.enable_login_form && $config?.features.enable_password_change_form}
<UserSettingSection title={$i18n.t('Password')}>
<UpdatePassword />
@@ -1,5 +1,4 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { getLanguages, changeLanguage } from '$lib/i18n';
const dispatch = createEventDispatcher();
@@ -14,7 +13,6 @@
import UserSettingRow from './UserSettingRow.svelte';
import UserSettingSection from './UserSettingSection.svelte';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
import Switch from '$lib/components/common/Switch.svelte';
export let saveSettings: Function;
export let getModels: Function;
@@ -24,7 +22,6 @@
let languages: Awaited<ReturnType<typeof getLanguages>> = [];
let lang = $i18n.language;
let notificationEnabled = false;
let system = '';
let showAdvanced = false;
@@ -34,22 +31,6 @@
const actionButtonClass =
'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white';
const setNotificationEnabled = async (enabled: boolean) => {
const permission = enabled ? await Notification.requestPermission() : 'granted';
if (permission === 'granted') {
notificationEnabled = enabled;
saveSettings({ notificationEnabled: notificationEnabled });
} 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.'
)
);
}
};
let params = {
// Advanced
stream_response: null,
@@ -138,7 +119,6 @@
languages = languages.filter((l) => l.code !== 'dg-DG');
}
notificationEnabled = $settings.notificationEnabled ?? false;
system = $settings.system ?? '';
params = { ...params, ...$settings.params };
@@ -270,19 +250,6 @@
</a>
</div>
{/if}
<UserSettingRow
label={$i18n.t('Notifications')}
description={$i18n.t('Allow browser notifications for completed responses.')}
>
<Switch
state={notificationEnabled}
ariaLabel={$i18n.t('Notifications')}
on:change={(event) => {
setNotificationEnabled(event.detail);
}}
/>
</UserSettingRow>
</UserSettingSection>
{#if $user?.role === 'admin' || (($user?.permissions.chat?.controls ?? true) && ($user?.permissions.chat?.system_prompt ?? true))}
@@ -39,9 +39,6 @@
let defaultModelId = '';
let showUsername = false;
let notificationSound = true;
let notificationSoundAlways = false;
let highContrastMode = false;
let detectArtifacts = true;
@@ -260,9 +257,6 @@
userLocation = $settings?.userLocation ?? false;
showChatTitleInTab = $settings?.showChatTitleInTab ?? true;
notificationSound = $settings?.notificationSound ?? true;
notificationSoundAlways = $settings?.notificationSoundAlways ?? false;
iframeSandboxAllowSameOrigin = $settings?.iframeSandboxAllowSameOrigin ?? false;
iframeSandboxAllowForms = $settings?.iframeSandboxAllowForms ?? false;
@@ -476,52 +470,6 @@
</p>
</div>
<div>
<div class={settingRowClass}>
<div id="notification-sound-label" class={settingLabelClass}>
{$i18n.t('Notification Sound')}
</div>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="notification-sound-label"
tooltip={true}
bind:state={notificationSound}
on:change={() => {
saveSettings({ notificationSound });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('Play a sound when new chat activity arrives.')}
</p>
</div>
{#if notificationSound}
<div>
<div class={settingRowClass}>
<div id="play-notification-sound-label" class={settingLabelClass}>
{$i18n.t('Always Play Notification Sound')}
</div>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="play-notification-sound-label"
tooltip={true}
bind:state={notificationSoundAlways}
on:change={() => {
saveSettings({ notificationSoundAlways });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('Play notification sounds even when the app is focused.')}
</p>
</div>
{/if}
<div>
<div id="allow-user-location-label" class={settingRowClass}>
<div class={settingLabelClass}>{$i18n.t('Allow User Location')}</div>
@@ -0,0 +1,345 @@
<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 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 {
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 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';
$: 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 (permission === 'granted') {
notificationEnabled = enabled;
saveSettings({ notificationEnabled });
} 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.'
)
);
}
};
const loadTargets = async () => {
if (!canUseWebhooks) {
return;
}
loading = true;
try {
events = await getNotificationEvents(localStorage.token);
const response = await getNotificationTargets(localStorage.token);
targets = response.targets ?? [];
defaultTargetId = response.default_target_id ?? null;
} catch (error) {
toast.error(`${error}`);
} finally {
loading = false;
}
};
const resetForm = () => {
editingTarget = null;
targetName = 'Webhook';
targetUrl = '';
targetEnabled = true;
targetDelivery = 'away';
targetEvents = ['chat.finished', 'chat.failed'];
};
const editTarget = (target: NotificationTarget) => {
editingTarget = target;
targetName = target.name;
targetUrl = '';
targetEnabled = target.enabled;
targetDelivery = target.delivery;
targetEvents = [...target.events];
};
const saveTarget = async () => {
try {
const payload: Partial<NotificationTarget> = {
type: 'webhook',
name: targetName,
enabled: targetEnabled,
events: targetEvents,
delivery: targetDelivery,
config: targetUrl ? { url: targetUrl } : {}
};
if (editingTarget) {
await updateNotificationTarget(localStorage.token, editingTarget.id, payload);
} else {
await createNotificationTarget(localStorage.token, payload);
}
toast.success($i18n.t('Settings saved successfully!'));
resetForm();
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];
}
};
onMount(async () => {
notificationEnabled = $settings.notificationEnabled ?? false;
notificationSound = $settings?.notificationSound ?? true;
notificationSoundAlways = $settings?.notificationSoundAlways ?? false;
await loadTargets();
});
</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>
<UserSettingSection title={$i18n.t('Local Notifications')} first>
<UserSettingRow
label={$i18n.t('Browser Notifications')}
description={$i18n.t('Allow browser notifications for completed responses.')}
>
<Switch
state={notificationEnabled}
ariaLabel={$i18n.t('Browser Notifications')}
on:change={(event) => {
setNotificationEnabled(event.detail);
}}
/>
</UserSettingRow>
<UserSettingRow
label={$i18n.t('Notification Sound')}
description={$i18n.t('Play a sound when new chat activity arrives.')}
>
<Switch
bind:state={notificationSound}
ariaLabel={$i18n.t('Notification Sound')}
on:change={() => {
saveSettings({ notificationSound });
}}
/>
</UserSettingRow>
{#if notificationSound}
<UserSettingRow
label={$i18n.t('Always Play Notification Sound')}
description={$i18n.t('Play notification sounds even when the app is focused.')}
>
<Switch
bind:state={notificationSoundAlways}
ariaLabel={$i18n.t('Always Play Notification Sound')}
on:change={() => {
saveSettings({ notificationSoundAlways });
}}
/>
</UserSettingRow>
{/if}
</UserSettingSection>
{#if canUseWebhooks}
<UserSettingSection title={$i18n.t('Webhook Targets')}>
{#if loading}
<div class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Loading...')}</div>
{:else if targets.length === 0}
<div class="text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('No notification targets configured.')}
</div>
{:else}
<div class="flex flex-col gap-2">
{#each targets as target}
<div
class="rounded-lg border border-gray-100/70 p-2.5 text-xs dark:border-white/[0.06]"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="truncate font-medium text-gray-700 dark:text-gray-300">
{target.name}
{#if target.id === defaultTargetId}
<span class="ml-1 text-gray-400 dark:text-gray-600"
>{$i18n.t('Default')}</span
>
{/if}
</div>
<div class="truncate text-gray-400 dark:text-gray-600">
{target.config?.url}
</div>
</div>
<Switch
state={target.enabled}
ariaLabel={$i18n.t('Enabled')}
on:change={async (event) => {
await updateNotificationTarget(localStorage.token, target.id, {
enabled: event.detail
});
await loadTargets();
}}
/>
</div>
<div class="mt-2 flex flex-wrap gap-2 text-gray-400 dark:text-gray-600">
<span
>{target.delivery === 'always'
? $i18n.t('Always')
: $i18n.t('Only when away')}</span
>
<span>{target.events.join(', ')}</span>
</div>
<div class="mt-2 flex flex-wrap gap-3">
<button
class={actionButtonClass}
type="button"
on:click={async () => {
await testNotificationTarget(localStorage.token, target.id);
toast.success($i18n.t('Test notification sent.'));
}}>{$i18n.t('Test')}</button
>
<button
class={actionButtonClass}
type="button"
on:click={() => editTarget(target)}>{$i18n.t('Edit')}</button
>
<button
class={actionButtonClass}
type="button"
disabled={target.id === defaultTargetId}
on:click={async () => {
await setDefaultNotificationTarget(localStorage.token, target.id);
await loadTargets();
}}>{$i18n.t('Make Default')}</button
>
<button
class={actionButtonClass}
type="button"
on:click={async () => {
await deleteNotificationTarget(localStorage.token, target.id);
await loadTargets();
}}>{$i18n.t('Remove')}</button
>
</div>
</div>
{/each}
</div>
{/if}
</UserSettingSection>
<UserSettingSection title={editingTarget ? $i18n.t('Edit Target') : $i18n.t('Add Target')}>
<UserSettingField label={$i18n.t('Name')}>
<input class={inputClass} type="text" bind:value={targetName} />
</UserSettingField>
<UserSettingField
label={$i18n.t('Webhook URL')}
description={editingTarget
? $i18n.t('Leave blank to keep the current webhook URL.')
: $i18n.t('Send chat notifications to this webhook URL.')}
>
<input
class={inputClass}
type="url"
bind:value={targetUrl}
placeholder={editingTarget
? editingTarget.config?.url
: $i18n.t('Enter your webhook URL')}
/>
</UserSettingField>
<UserSettingRow label={$i18n.t('Enabled')}>
<Switch bind:state={targetEnabled} ariaLabel={$i18n.t('Enabled')} />
</UserSettingRow>
<UserSettingField label={$i18n.t('Delivery')}>
<SettingsSelect bind:value={targetDelivery} ariaLabel={$i18n.t('Delivery')}>
<option value="away">{$i18n.t('Only when away')}</option>
<option value="always">{$i18n.t('Always')}</option>
</SettingsSelect>
</UserSettingField>
<UserSettingField label={$i18n.t('Events')}>
<div class="flex flex-col gap-1.5">
{#each events as item}
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={targetEvents.includes(item.event)}
on:change={() => toggleEvent(item.event)}
/>
<span>{$i18n.t(item.label)}</span>
</label>
{/each}
</div>
</UserSettingField>
<div class="flex justify-end gap-3">
{#if editingTarget}
<button class={actionButtonClass} type="button" on:click={resetForm}
>{$i18n.t('Cancel')}</button
>
{/if}
<button
class="rounded-full bg-black px-3.5 py-1.5 text-sm font-normal text-white transition hover:bg-gray-900 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-gray-100"
type="button"
disabled={!editingTarget && !targetUrl}
on:click={saveTarget}
>
{editingTarget ? $i18n.t('Save') : $i18n.t('Add')}
</button>
</div>
</UserSettingSection>
{/if}
</div>
</div>
+33 -2
View File
@@ -12,6 +12,7 @@
import About from './Settings/About.svelte';
import General from './Settings/General.svelte';
import Interface from './Settings/Interface.svelte';
import Notifications from './Settings/Notifications.svelte';
import Shortcuts from './Settings/Shortcuts.svelte';
import Audio from './Settings/Audio.svelte';
import DataControls from './Settings/DataControls.svelte';
@@ -98,6 +99,7 @@
const personalSettingGroups: Record<string, string> = {
general: 'Basics',
interface: 'Basics',
notifications: 'Basics',
shortcuts: 'Basics',
connections: 'Services',
tools: 'Services',
@@ -153,7 +155,6 @@
'keepalive',
'keep alive',
'languages',
'notifications',
'requestmode',
'request mode',
'systemparameters',
@@ -295,6 +296,22 @@
'web search in chat'
]
},
{
id: 'notifications',
title: 'Notifications',
keywords: [
'browser notifications',
'browsernotifications',
'chat failed',
'chat finished',
'notification sound',
'notifications',
'notify',
'webhook',
'webhook notifications',
'webhooks'
]
},
{
id: 'shortcuts',
title: 'Keyboard',
@@ -900,6 +917,19 @@
<AppNotification className="size-3.5" strokeWidth="2" />
<span>{$i18n.t('Interface')}</span>
</button>
{:else if tabId === 'notifications'}
<button
role="tab"
aria-controls="tab-notifications"
aria-selected={selectedTab === 'notifications'}
class={tabButtonClass(selectedTab === 'notifications')}
on:click={() => {
selectedTab = 'notifications';
}}
>
<AppNotification className="size-3.5" strokeWidth="2" />
<span>{$i18n.t('Notifications')}</span>
</button>
{:else if tabId === 'shortcuts'}
<button
role="tab"
@@ -1083,6 +1113,8 @@
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'notifications'}
<Notifications {saveSettings} />
{:else if selectedTab === 'shortcuts'}
<Shortcuts />
{:else if selectedTab === 'connections'}
@@ -1119,7 +1151,6 @@
<ArchivedChats />
{:else if selectedTab === 'account'}
<Account
{saveSettings}
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
@@ -31,6 +31,10 @@
label: $i18n.t('Channels'),
description: $i18n.t('Search channels and channel messages')
},
notifications: {
label: $i18n.t('Notifications'),
description: $i18n.t('Send notifications to configured webhook targets')
},
web_search: {
label: $i18n.t('Web Search'),
description: $i18n.t('Search the web and fetch URLs')