This commit is contained in:
Timothy Jaeryang Baek 2026-09-07 14:46:52 -04:00
parent 675b9839f1
commit 98a920168e
4 changed files with 172 additions and 46 deletions

View file

@ -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)

View file

@ -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(

View file

@ -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<string, any> = {};
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}
</UserSettingSection>
{#if $user?.role === 'admin' || (($user?.permissions.chat?.controls ?? true) && ($user?.permissions.chat?.system_prompt ?? true))}
{#if canEditSystemPrompt}
<UserSettingSection title={$i18n.t('System Prompt')}>
<UserSettingField description={$i18n.t('Set the default system prompt for new chats.')}>
<Textarea
@ -268,7 +284,7 @@
</UserSettingSection>
{/if}
{#if $user?.role === 'admin' || (($user?.permissions.chat?.controls ?? true) && ($user?.permissions.chat?.params ?? true))}
{#if canEditParams}
<UserSettingSection title={$i18n.t('Advanced Parameters')}>
<UserSettingRow description={$i18n.t('Show or hide custom generation parameters.')}>
<span slot="label">{$i18n.t('Model parameters')}</span>

View file

@ -857,15 +857,18 @@
};
const saveSettings = async (updated: Record<string, any>) => {
console.log(updated);
await settings.set({ ...$settings, ...updated });
await models.set(await getModels());
const saved = await updateUserSettings(localStorage.token, { ui: $settings });
const saved = await updateUserSettings(localStorage.token, {
ui: updated
}).catch((error) => {
toast.error(`${error}`);
throw error;
});
personalUiSettings =
saved?.ui && typeof saved.ui === 'object' && !Array.isArray(saved.ui) ? saved.ui : {};
await settings.set(
mergeUiSettings($config?.ui?.default_interface_settings ?? {}, personalUiSettings)
);
await models.set(await getModels());
};
const getModels = async () => {