From 98a920168e2eea435ac15e1ad3d679946631e41d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 7 Sep 2026 14:46:52 -0400 Subject: [PATCH] refac --- backend/open_webui/models/users.py | 112 +++++++++++++++++- backend/open_webui/routers/users.py | 63 +++++----- .../components/chat/Settings/General.svelte | 32 +++-- src/lib/components/chat/SettingsModal.svelte | 11 +- 4 files changed, 172 insertions(+), 46 deletions(-) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index b7633759f5..057f50571b 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -4,12 +4,18 @@ from __future__ import annotations import datetime import time -from typing import Optional +from typing import Literal, Optional from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.utils.misc import throttle from open_webui.utils.validate import validate_profile_image_url -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) from sqlalchemy import ( JSON, BigInteger, @@ -36,6 +42,97 @@ from sqlalchemy.ext.asyncio import AsyncSession #################### +class InterfaceTitleSettings(BaseModel): + model_config = ConfigDict(extra='forbid') + + auto: bool | None = None + + +class InterfaceImageCompressionSize(BaseModel): + model_config = ConfigDict(extra='forbid') + + width: int | float | Literal[''] | None = None + height: int | float | Literal[''] | None = None + + +class InterfaceFloatingActionButton(BaseModel): + model_config = ConfigDict(extra='forbid') + + id: str + label: str + input: bool + prompt: str + + +class InterfaceSettings(BaseModel): + """Fields owned by the Interface settings panel; not the entire user UI dict.""" + + model_config = ConfigDict(extra='forbid') + + autoTags: bool | None = None + autoFollowUps: bool | None = None + highContrastMode: bool | None = None + detectArtifacts: bool | None = None + responseAutoCopy: bool | None = None + showUsername: bool | None = None + showUpdateToast: bool | None = None + showChangelog: bool | None = None + showEmojiInCall: bool | None = None + voiceInterruption: bool | None = None + displayMultiModelResponsesInTabs: bool | None = None + chatFadeStreamingText: bool | None = None + richTextInput: bool | None = None + showFormattingToolbar: bool | None = None + insertPromptAsRichText: bool | None = None + promptAutocomplete: bool | None = None + insertSuggestionPrompt: bool | None = None + keepFollowUpPrompts: bool | None = None + insertFollowUpPrompt: bool | None = None + regenerateMenu: bool | None = None + enableMessageQueue: bool | None = None + largeTextAsFile: bool | None = None + copyFormatted: bool | None = None + collapseCodeBlocks: bool | None = None + renderMarkdownInUserMessages: bool | None = None + renderMarkdownInAssistantMessages: bool | None = None + expandDetails: bool | None = None + chatHoverPreview: bool | None = None + renderMarkdownInPreviews: bool | None = None + chatBubble: bool | None = None + widescreenMode: bool | None = None + splitLargeChunks: bool | None = None + scrollOnBranchChange: bool | None = None + scrollOnResponseGeneration: bool | None = None + showFilesOnTerminalSelect: bool | None = None + temporaryChatByDefault: bool | None = None + userLocation: bool | None = None + showChatTitleInTab: bool | None = None + iframeSandboxAllowScripts: bool | None = None + iframeSandboxAllowSameOrigin: bool | None = None + iframeSandboxAllowForms: bool | None = None + iframeSandboxAllowDownloads: bool | None = None + terminalPreviewAllowSameOrigin: bool | None = None + stylizedPdfExport: bool | None = None + hapticFeedback: bool | None = None + ctrlEnterToSend: bool | None = None + showFloatingActionButtons: bool | None = None + imageCompression: bool | None = None + imageCompressionInChannels: bool | None = None + + landingPageMode: Literal['', 'chat'] | None = None + chatDirection: Literal['LTR', 'RTL', 'auto'] | None = None + terminalFileDisplay: Literal['sidebar', 'inline'] | None = None + defaultUploadContext: Literal['full', 'focused'] | None = None + webSearch: Literal['always'] | None = None + models: list[str] | None = None + backgroundImageUrl: str | None = None + fontFamily: str | None = None + textScale: float | None = None + title: InterfaceTitleSettings | None = None + imageCompressionSize: InterfaceImageCompressionSize | None = None + floatingActionButtons: list[InterfaceFloatingActionButton] | None = None + + class UserSettings(BaseModel): ui: dict | None = {} model_config = ConfigDict(extra='allow') @@ -729,7 +826,18 @@ class UsersTable: if not user: return None user_settings = dict(user.settings or {}) + updated = dict(updated) + ui_settings = updated.pop('ui', None) user_settings.update(updated) + if ui_settings is not None: + # UI updates are field-level patches: omission keeps a value; null resets it. + current_ui_settings = dict(user_settings.get('ui') or {}) + for key, value in ui_settings.items(): + if value is None: + current_ui_settings.pop(key, None) + else: + current_ui_settings[key] = value + user_settings['ui'] = current_ui_settings user.settings = user_settings await session.commit() return UserModel.model_validate(user) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index d736c038bc..1391727ded 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -21,6 +21,7 @@ from open_webui.models.chats import Chats from open_webui.models.groups import Groups from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import ( + InterfaceSettings, UserGroupIdsListResponse, UserGroupIdsModel, UserInfoListResponse, @@ -68,23 +69,6 @@ def merge_user_ui_settings(defaults: dict, settings: dict) -> dict: return merged -def strip_default_interface_settings(defaults: dict, settings: dict) -> dict: - stripped = {} - for key, value in settings.items(): - if value is None: - continue - - default_value = defaults.get(key) - if isinstance(default_value, dict) and isinstance(value, dict): - nested = strip_default_interface_settings(default_value, value) - if nested: - stripped[key] = nested - elif value != default_value: - stripped[key] = value - - return stripped - - ############################ # GetUsers # A house is only as strong as its care for the least of @@ -502,16 +486,37 @@ async def update_user_settings_by_session_user( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not await has_permission( - user.id, 'settings.interface', await Config.get('user.permissions') - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) - updated_user_settings = form_data.model_dump(exclude_unset=True) ui_settings = updated_user_settings.get('ui') + + if isinstance(ui_settings, dict): + if user.role != 'admin' and not await has_permission( + user.id, 'settings.interface', await Config.get('user.permissions'), db=db + ): + # Omitted fields are unchanged, so unauthorized Interface fields can be discarded. + for key in InterfaceSettings.model_fields: + ui_settings.pop(key, None) + + if ( + user.role != 'admin' + and 'system' in ui_settings + and ( + not await has_permission(user.id, 'chat.controls', await Config.get('user.permissions'), db=db) + or not await has_permission(user.id, 'chat.system_prompt', await Config.get('user.permissions'), db=db) + ) + ): + ui_settings.pop('system', None) + + if ( + user.role != 'admin' + and 'params' in ui_settings + and ( + not await has_permission(user.id, 'chat.controls', await Config.get('user.permissions'), db=db) + or not await has_permission(user.id, 'chat.params', await Config.get('user.permissions'), db=db) + ) + ): + ui_settings.pop('params', None) + if ( user.role != 'admin' and ui_settings is not None @@ -522,8 +527,7 @@ async def update_user_settings_by_session_user( await Config.get('user.permissions'), ) ): - # If the user is not an admin and does not have permission to use tool servers, remove the key - updated_user_settings['ui'].pop('toolServers', None) + ui_settings.pop('toolServers', None) ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None if ( @@ -542,11 +546,6 @@ async def update_user_settings_by_session_user( if isinstance(ui_notifications, dict): ui_notifications.pop('webhook_url', None) - default_interface_settings = await Config.get('ui.default_interface_settings') - ui_settings = updated_user_settings.get('ui') - if isinstance(default_interface_settings, dict) and isinstance(ui_settings, dict): - updated_user_settings['ui'] = strip_default_interface_settings(default_interface_settings, ui_settings) - user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db) if user: await publish_event( diff --git a/src/lib/components/chat/Settings/General.svelte b/src/lib/components/chat/Settings/General.svelte index d32aa3e51a..a80c6cb1fd 100644 --- a/src/lib/components/chat/Settings/General.svelte +++ b/src/lib/components/chat/Settings/General.svelte @@ -66,10 +66,21 @@ keep_alive: null }; + $: canEditSystemPrompt = + $user?.role === 'admin' || + (($user?.permissions.chat?.controls ?? true) && + ($user?.permissions.chat?.system_prompt ?? true)); + $: canEditParams = + $user?.role === 'admin' || + (($user?.permissions.chat?.controls ?? true) && ($user?.permissions.chat?.params ?? true)); + const saveHandler = async () => { - saveSettings({ - system: system !== '' ? system : undefined, - params: { + const updated: Record = {}; + if (canEditSystemPrompt) { + updated.system = system !== '' ? system : null; + } + if (canEditParams) { + updated.params = { stream_response: params.stream_response !== null ? params.stream_response : undefined, stream_delta_chunk_size: params.stream_delta_chunk_size !== null ? params.stream_delta_chunk_size : undefined, @@ -105,9 +116,14 @@ ...(params.custom_params && Object.keys(params.custom_params).length > 0 ? { custom_params: params.custom_params } : {}) - } - }); - dispatch('save'); + }; + } + try { + await saveSettings(updated); + dispatch('save'); + } catch { + // The settings modal displays the save error; do not report success. + } }; onMount(async () => { @@ -255,7 +271,7 @@ {/if} - {#if $user?.role === 'admin' || (($user?.permissions.chat?.controls ?? true) && ($user?.permissions.chat?.system_prompt ?? true))} + {#if canEditSystemPrompt}