diff --git a/backend/open_webui/events.py b/backend/open_webui/events.py index 4aa30f9128..a99ef60f67 100644 --- a/backend/open_webui/events.py +++ b/backend/open_webui/events.py @@ -161,6 +161,12 @@ class EventDefinitions(BaseModel): CHAT_CREATED: EventDefinition = EventDefinition( name='chat.created', description='A chat was created.', message='Chat created' ) + CHAT_FINISHED: EventDefinition = EventDefinition( + name='chat.finished', description='A chat response finished.', message='Chat finished' + ) + CHAT_FAILED: EventDefinition = EventDefinition( + name='chat.failed', description='A chat response failed.', message='Chat failed' + ) CHAT_IMPORTED: EventDefinition = EventDefinition( name='chat.imported', description='A chat was imported.', message='Chat imported' ) @@ -622,6 +628,12 @@ class EventDefinitions(BaseModel): TERMINAL_SESSION_CLOSED: EventDefinition = EventDefinition( name='terminal.session.closed', description='A terminal session was closed.', message='Terminal Session closed' ) + NOTIFICATION_TEST: EventDefinition = EventDefinition( + name='notification.test', description='A notification target test was sent.', message='Notification test' + ) + NOTIFICATION_MANUAL: EventDefinition = EventDefinition( + name='notification.manual', description='A manual notification was sent.', message='Notification sent' + ) EVENTS = EventDefinitions() @@ -629,6 +641,7 @@ EVENT_DEFINITIONS = tuple(getattr(EVENTS, field_name) for field_name in EventDef EVENT_DEFINITIONS_BY_NAME = {definition.name: definition for definition in EVENT_DEFINITIONS} EVENT_CATALOG = tuple(definition.name for definition in EVENT_DEFINITIONS) EVENT_CATALOG_SET = set(EVENT_CATALOG) +CHAT_NOTIFICATION_EVENTS = (EVENTS.CHAT_FINISHED.name, EVENTS.CHAT_FAILED.name) def get_event_catalog() -> list[dict[str, str]]: @@ -1024,6 +1037,21 @@ class WebhookEventSink: schedule_webhook_dispatch(app, event) +def schedule_notification_dispatch(app: Any, event: Event) -> None: + try: + from open_webui.utils.notifications import dispatch_notification_event + + asyncio.create_task(dispatch_notification_event(app, event)) + except RuntimeError: + log.exception('Notification delivery could not be scheduled for %s', event.event) + + +class NotificationEventSink: + async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None: + if event.event in CHAT_NOTIFICATION_EVENTS: + schedule_notification_dispatch(app, event) + + async def dispatch_event_functions(app: Any, event: Event, request: Any | None = None) -> None: if not ENABLE_PLUGINS: return @@ -1084,7 +1112,7 @@ class EventFunctionSink: schedule_event_function_dispatch(app, event, request) -EVENT_SINKS = [EventFunctionSink(), WebhookEventSink()] +EVENT_SINKS = [EventFunctionSink(), WebhookEventSink(), NotificationEventSink()] async def publish_event( diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 229d389710..af27738a4a 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -154,6 +154,7 @@ from open_webui.routers import ( knowledge, memories, models, + notifications, notes, ollama, openai, @@ -761,6 +762,7 @@ app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes']) app.include_router(models.router, prefix='/api/v1/models', tags=['models']) +app.include_router(notifications.router, prefix='/api/v1/notifications', tags=['notifications']) app.include_router(knowledge.router, prefix='/api/v1/knowledge', tags=['knowledge']) app.include_router(prompts.router, prefix='/api/v1/prompts', tags=['prompts']) app.include_router(tools.router, prefix='/api/v1/tools', tags=['tools']) diff --git a/backend/open_webui/routers/notifications.py b/backend/open_webui/routers/notifications.py new file mode 100644 index 0000000000..9857980dbc --- /dev/null +++ b/backend/open_webui/routers/notifications.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field + +from open_webui.constants import ERROR_MESSAGES +from open_webui.models.config import Config +from open_webui.utils.access_control import has_permission +from open_webui.utils.auth import get_verified_user +from open_webui.utils.notifications import ( + create_target, + delete_target, + list_targets, + set_default_target, + test_target, + update_target, +) + +router = APIRouter() + + +class NotificationTargetForm(BaseModel): + id: str | None = None + type: str = 'webhook' + name: str = Field(default='Webhook') + enabled: bool = True + events: list[str] = Field(default_factory=lambda: ['chat.finished', 'chat.failed']) + delivery: str = 'away' + config: dict[str, Any] = Field(default_factory=dict) + + +async def _check_notifications_access(user) -> None: + if not await Config.get('ui.enable_user_webhooks'): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + if user.role != 'admin' and not await has_permission( + user.id, 'features.webhooks', await Config.get('user.permissions') + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) + + +@router.get('/events') +async def get_notification_events(user=Depends(get_verified_user)): + await _check_notifications_access(user) + return [ + {'event': 'chat.finished', 'label': 'Chat finished'}, + {'event': 'chat.failed', 'label': 'Chat failed'}, + ] + + +@router.get('/targets') +async def get_notification_targets(user=Depends(get_verified_user)): + await _check_notifications_access(user) + return await list_targets(user.id) + + +@router.post('/targets') +async def create_notification_target(form_data: NotificationTargetForm, user=Depends(get_verified_user)): + await _check_notifications_access(user) + try: + return await create_target(user.id, form_data.model_dump()) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.put('/targets/{target_id}') +async def update_notification_target( + target_id: str, form_data: NotificationTargetForm, user=Depends(get_verified_user) +): + await _check_notifications_access(user) + try: + return await update_target(user.id, target_id, form_data.model_dump(exclude_unset=True)) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.delete('/targets/{target_id}') +async def delete_notification_target(target_id: str, user=Depends(get_verified_user)): + await _check_notifications_access(user) + if not await delete_target(user.id, target_id): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + return True + + +@router.put('/targets/{target_id}/default') +async def set_default_notification_target(target_id: str, user=Depends(get_verified_user)): + await _check_notifications_access(user) + try: + return await set_default_target(user.id, target_id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.post('/targets/{target_id}/test') +async def test_notification_target(request: Request, target_id: str, user=Depends(get_verified_user)): + await _check_notifications_access(user) + try: + app_name = getattr(request.app.state, 'WEBUI_NAME', 'Open WebUI') + return await test_target(user.id, target_id, app_name) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 5b529b7c5c..1d1bf8572d 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -52,6 +52,7 @@ from open_webui.routers.retrieval import search_web as _search_web from open_webui.tasks import stop_item_tasks from open_webui.events import EVENTS, publish_event from open_webui.socket.main import sio +from open_webui.utils.notifications import notify_target from open_webui.utils.sanitize import sanitize_code log = logging.getLogger(__name__) @@ -111,6 +112,33 @@ async def _has_read_access_to_file( # ============================================================================= +async def notify( + message: str, + target: str = '', + title: str = '', + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Send a notification to the user's configured notification target. + + :param message: Notification body. + :param target: Optional target id or name. Empty uses the default target. + :param title: Optional notification title. + """ + user_id = (__user__ or {}).get('id') + if not user_id: + return 'Notification failed: user not found.' + + app_name = getattr(getattr(__request__, 'app', None), 'state', None) + app_name = getattr(app_name, 'WEBUI_NAME', 'Open WebUI') + try: + result = await notify_target(user_id, message, target=target, title=title, app_name=app_name) + return f'Notification sent to {result.get("target_id")}.' + except Exception as e: + return f'Notification failed: {e}' + + async def get_current_timestamp( __request__: Request = None, __user__: dict = None, diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 9ea9a5cc96..0c2724b46f 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -48,6 +48,7 @@ from open_webui.models.models import Models from open_webui.models.notes import Notes from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import UserModel, Users +from open_webui.events import EVENTS, publish_event from open_webui.retrieval.utils import get_sources_from_items from open_webui.routers.images import ( CreateImageForm, @@ -129,13 +130,37 @@ from open_webui.utils.tools import ( get_tools, get_updated_tool_function, ) -from open_webui.utils.webhook import post_webhook from starlette.responses import JSONResponse, Response, StreamingResponse logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) +async def publish_chat_finished_event(request: Request, user: UserModel, metadata: dict, title: str, content: str): + chat_id = metadata.get('chat_id') + if getattr(request.state, 'internal', False) is True or not chat_id or chat_id.startswith(('channel:', 'local:')): + return + + webui_url = await Config.get('webui.url') + await publish_event( + request, + EVENTS.CHAT_FINISHED, + actor=user, + subject_id=chat_id, + subject_type='chat', + data={ + 'user_id': user.id, + 'chat_id': chat_id, + 'message_id': metadata.get('message_id'), + 'model_id': metadata.get('model_id'), + 'title': title, + 'url': f'{webui_url}/c/{chat_id}' if webui_url else f'/c/{chat_id}', + 'message': content, + }, + message=title or 'Chat finished', + ) + + # We believe in one maker of all models, seen and unseen, # and in the reasoning which proceeds from the architect. # We look for the resurrection of dead processes and the @@ -3585,26 +3610,7 @@ async def non_streaming_chat_response_handler(response, ctx): }, ) - # Send a webhook notification if the user is not active - if ( - getattr(request.state, 'internal', False) is not True - and await Config.get('ui.enable_user_webhooks') - and not await Users.is_user_active(user.id) - ): - webhook_url = await Users.get_user_webhook_url_by_id(user.id) - if webhook_url: - webui_url = await Config.get('webui.url') - await post_webhook( - request.app.state.WEBUI_NAME, - webhook_url, - f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}', - { - 'action': 'chat', - 'message': content, - 'title': title, - 'url': f'{webui_url}/c/{metadata["chat_id"]}', - }, - ) + await publish_chat_finished_event(request, user, metadata, title, content) ctx['assistant_message'] = { 'content': content, @@ -3617,6 +3623,29 @@ async def non_streaming_chat_response_handler(response, ctx): response = build_response_object(response, merge_events_into_response(response_data, events)) except Exception as e: log.debug(f'Error occurred while processing request: {e}') + chat_id = metadata.get('chat_id') + if ( + getattr(request.state, 'internal', False) is not True + and chat_id + and not chat_id.startswith(('channel:', 'local:')) + ): + webui_url = await Config.get('webui.url') + await publish_event( + request, + EVENTS.CHAT_FAILED, + actor=user, + subject_id=chat_id, + subject_type='chat', + data={ + 'user_id': user.id, + 'chat_id': chat_id, + 'message_id': metadata.get('message_id'), + 'model_id': metadata.get('model_id'), + 'url': f'{webui_url}/c/{chat_id}' if webui_url else f'/c/{chat_id}', + 'message': str(e), + }, + message='Chat failed', + ) pass return response @@ -5310,26 +5339,7 @@ async def streaming_chat_response_handler(response, ctx): touch=False, ) - # Send a webhook notification if the user is not active - if ( - getattr(request.state, 'internal', False) is not True - and await Config.get('ui.enable_user_webhooks') - and not await Users.is_user_active(user.id) - ): - webhook_url = await Users.get_user_webhook_url_by_id(user.id) - if webhook_url: - webui_url = await Config.get('webui.url') - await post_webhook( - request.app.state.WEBUI_NAME, - webhook_url, - f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}', - { - 'action': 'chat', - 'message': content, - 'title': title, - 'url': f'{webui_url}/c/{metadata["chat_id"]}', - }, - ) + await publish_chat_finished_event(request, user, metadata, title, content) await event_emitter( { diff --git a/backend/open_webui/utils/notifications.py b/backend/open_webui/utils/notifications.py new file mode 100644 index 0000000000..2884aeddb4 --- /dev/null +++ b/backend/open_webui/utils/notifications.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import logging +import time +import uuid +from typing import Any + +from open_webui.models.config import Config +from open_webui.models.users import Users +from open_webui.retrieval.web.utils import validate_url +from open_webui.utils.webhook import post_webhook + + +VALID_EVENTS = {'chat.finished', 'chat.failed'} +VALID_DELIVERY = {'away', 'always'} + +DEFAULT_TARGET_ID = 'webhook' +log = logging.getLogger(__name__) + + +def _normalize_target(target: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]: + existing = existing or {} + now = int(time.time()) + + target_type = str(target.get('type') or existing.get('type') or 'webhook').strip() + if target_type != 'webhook': + raise ValueError('Unsupported notification target type') + + name = str(target.get('name') or existing.get('name') or 'Webhook').strip() or 'Webhook' + slug = ''.join(ch.lower() if ch.isalnum() else '-' for ch in name).strip('-') + while '--' in slug: + slug = slug.replace('--', '-') + target_id = str(target.get('id') or existing.get('id') or slug[:48] or uuid.uuid4().hex[:8]).strip() + + config = dict(existing.get('config') or {}) + config.update(target.get('config') or {}) + url = str(config.get('url') or '').strip() + if not url: + raise ValueError('Webhook URL is required') + if '...' in url: + url = str((existing.get('config') or {}).get('url') or '').strip() + 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') + + delivery = str(target.get('delivery') or existing.get('delivery') or 'away') + if delivery not in VALID_DELIVERY: + raise ValueError('Invalid notification delivery mode') + + return { + 'id': target_id, + 'type': target_type, + 'name': name, + 'enabled': bool(target.get('enabled', existing.get('enabled', True))), + 'events': events, + 'delivery': delivery, + 'config': config, + 'created_at': int(existing.get('created_at') or now), + 'updated_at': now, + } + + +def _public_target(target: dict[str, Any]) -> dict[str, Any]: + config = dict(target.get('config') or {}) + if config.get('url'): + url = str(config['url']) + config['url'] = '********' if len(url) <= 12 else f'{url[:8]}...{url[-4:]}' + return {**target, 'config': config} + + +async def _load_notifications(user_id: str) -> dict[str, Any]: + user = await Users.get_user_by_id(user_id) + if not user: + raise ValueError('User not found') + + settings = getattr(user, 'settings', None) + settings = settings.model_dump(exclude_none=True) if hasattr(settings, 'model_dump') else dict(settings or {}) + notifications = dict(settings.get('notifications') or {}) + 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() + if legacy_url: + target = _normalize_target( + { + 'id': DEFAULT_TARGET_ID, + 'name': 'Webhook', + 'type': 'webhook', + 'enabled': True, + 'events': sorted(VALID_EVENTS), + 'delivery': 'away', + 'config': {'url': legacy_url}, + } + ) + 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)] + notifications.setdefault( + 'default_target_id', notifications['targets'][0].get('id') if notifications['targets'] else None + ) + + return notifications + + +async def list_targets(user_id: str) -> dict[str, Any]: + notifications = await _load_notifications(user_id) + return { + 'targets': [_public_target(target) for target in notifications.get('targets') or []], + 'default_target_id': notifications.get('default_target_id'), + } + + +async def create_target(user_id: str, payload: dict[str, Any]) -> dict[str, Any]: + notifications = await _load_notifications(user_id) + targets = notifications.get('targets') or [] + target = _normalize_target(payload) + if any(existing.get('id') == target['id'] for existing in targets): + target['id'] = f'{target["id"]}-{uuid.uuid4().hex[:6]}' + targets.append(target) + notifications['targets'] = targets + notifications.setdefault('default_target_id', target['id']) + await Users.update_user_settings_by_id(user_id, {'notifications': notifications}) + return _public_target(target) + + +async def update_target(user_id: str, target_id: str, payload: dict[str, Any]) -> dict[str, Any]: + notifications = await _load_notifications(user_id) + targets = notifications.get('targets') or [] + for index, existing in enumerate(targets): + if existing.get('id') == target_id: + updated = _normalize_target({'id': target_id, **payload}, existing=existing) + targets[index] = updated + notifications['targets'] = targets + await Users.update_user_settings_by_id(user_id, {'notifications': notifications}) + return _public_target(updated) + raise ValueError('Notification target not found') + + +async def delete_target(user_id: str, target_id: str) -> bool: + notifications = await _load_notifications(user_id) + targets = notifications.get('targets') or [] + next_targets = [target for target in targets if target.get('id') != target_id] + if len(next_targets) == len(targets): + return False + notifications['targets'] = next_targets + if notifications.get('default_target_id') == target_id: + notifications['default_target_id'] = next_targets[0].get('id') if next_targets else None + await Users.update_user_settings_by_id(user_id, {'notifications': notifications}) + return True + + +async def set_default_target(user_id: str, target_id: str) -> dict[str, Any]: + notifications = await _load_notifications(user_id) + if not any(target.get('id') == target_id for target in notifications.get('targets') or []): + raise ValueError('Notification target not found') + notifications['default_target_id'] = target_id + await Users.update_user_settings_by_id(user_id, {'notifications': notifications}) + return await list_targets(user_id) + + +def _find_target(notifications: dict[str, Any], target: str = '') -> dict[str, Any] | None: + targets = notifications.get('targets') or [] + target = target.strip() + target_id = target or str(notifications.get('default_target_id') or '') + for item in targets: + if item.get('id') == target_id or item.get('name') == target: + return item + return None + + +async def _send_webhook(app_name: str, target: dict[str, Any], message: str, data: dict[str, Any], title: str = ''): + url = str((target.get('config') or {}).get('url') or '').strip() + if not url: + raise ValueError('Webhook URL is required') + ok = await post_webhook(app_name, url, title or message, data, description=message if title else None) + if not ok: + raise ValueError('Webhook delivery failed') + + +async def test_target(user_id: str, target_id: str, app_name: str = 'Open WebUI') -> dict[str, Any]: + notifications = await _load_notifications(user_id) + target = _find_target(notifications, target_id) + if not target: + raise ValueError('Notification target not found') + await _send_webhook( + app_name, + target, + 'This is a test notification from Open WebUI.', + {'event': 'notification.test', 'action': 'test', 'user_id': user_id}, + 'Test notification', + ) + return {'ok': True} + + +async def notify_target( + user_id: str, message: str, target: str = '', title: str = '', app_name: str = 'Open WebUI' +) -> dict[str, Any]: + notifications = await _load_notifications(user_id) + item = _find_target(notifications, target) + if not item: + raise ValueError('Notification target not found') + if not item.get('enabled', True): + raise ValueError('Notification target is disabled') + await _send_webhook( + app_name, + item, + message, + {'event': 'notification.manual', 'action': 'notify', 'user_id': user_id, 'message': message, 'title': title}, + title or 'Notification', + ) + return {'ok': True, 'target_id': item.get('id')} + + +async def dispatch_notification_event(app: Any, event: Any) -> None: + if event.event not in VALID_EVENTS or not await Config.get('ui.enable_user_webhooks'): + return + + from open_webui.events import event_user_ids + + app_name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI') + for user_id in event_user_ids(event): + try: + notifications = await _load_notifications(user_id) + is_active = await Users.is_user_active(user_id) + + for target in notifications.get('targets') or []: + if not target.get('enabled', True): + continue + if event.event not in target.get('events', []): + continue + if target.get('delivery', 'away') == 'away' and is_active: + continue + + data = event.model_dump() + title = event.message or data.get('message') or event.event + message = str((event.data or {}).get('message') or title) + await _send_webhook(app_name, target, message, data, title) + except Exception: + log.exception('Notification delivery failed for user %s and event %s', user_id, event.event) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index e0fb88de5a..c78b315304 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -68,6 +68,7 @@ from open_webui.tools.builtin import ( list_knowledge_bases, list_memories, list_memory_paths, + notify, query_knowledge_bases, query_knowledge_files, read_memory_path, @@ -503,6 +504,7 @@ async def get_builtin_tools( 'channels.enable', 'automations.enable', 'calendar.enable', + 'ui.enable_user_webhooks', 'subagents.enable', 'subagents.background_enabled', ) @@ -684,6 +686,13 @@ async def get_builtin_tools( [search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event] ) + if ( + is_builtin_tool_enabled('notifications') + and config.get('ui.enable_user_webhooks') + and await has_user_permission('webhooks') + ): + builtin_functions.append(notify) + if getattr(request.state, 'internal', False) is True: from open_webui.utils.subagents import MUTATING_MEMORY_TOOLS diff --git a/src/lib/apis/notifications/index.ts b/src/lib/apis/notifications/index.ts new file mode 100644 index 0000000000..ec09cdc670 --- /dev/null +++ b/src/lib/apis/notifications/index.ts @@ -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 +) => jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets`, token, 'POST', target); + +export const updateNotificationTarget = async ( + token: string, + targetId: string, + target: Partial +) => 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'); diff --git a/src/lib/components/chat/Settings/Account.svelte b/src/lib/components/chat/Settings/Account.svelte index facbd435c5..a021ef6da0 100644 --- a/src/lib/components/chat/Settings/Account.svelte +++ b/src/lib/components/chat/Settings/Account.svelte @@ -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 @@ - {#if $config?.features?.enable_user_webhooks && ($user?.role === 'admin' || ($user?.permissions?.features?.webhooks ?? false))} - - - - - - {/if} - {#if $config?.features.enable_login_form && $config?.features.enable_password_change_form} diff --git a/src/lib/components/chat/Settings/General.svelte b/src/lib/components/chat/Settings/General.svelte index 6ca3fd65c5..bdfdec1b4f 100644 --- a/src/lib/components/chat/Settings/General.svelte +++ b/src/lib/components/chat/Settings/General.svelte @@ -1,5 +1,4 @@ + +
+
+

+ {$i18n.t('Notifications')} +

+ + + + { + setNotificationEnabled(event.detail); + }} + /> + + + + { + saveSettings({ notificationSound }); + }} + /> + + + {#if notificationSound} + + { + saveSettings({ notificationSoundAlways }); + }} + /> + + {/if} + + + {#if canUseWebhooks} + + {#if loading} +
{$i18n.t('Loading...')}
+ {:else if targets.length === 0} +
+ {$i18n.t('No notification targets configured.')} +
+ {:else} +
+ {#each targets as target} +
+
+
+
+ {target.name} + {#if target.id === defaultTargetId} + {$i18n.t('Default')} + {/if} +
+
+ {target.config?.url} +
+
+ + { + 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(', ')} +
+ +
+ + + + +
+
+ {/each} +
+ {/if} +
+ + + + + + + + + + + + + + + + + + + + + + +
+ {#each events as item} + + {/each} +
+
+ +
+ {#if editingTarget} + + {/if} + +
+
+ {/if} +
+
diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte index 528f3865c6..a23bdc6fd9 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -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 = { 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 @@ {$i18n.t('Interface')} + {:else if tabId === 'notifications'} + {:else if tabId === 'shortcuts'}