refac
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user