From 67ac1a4e937271a02bfb02a330aa99dc8192cbf4 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 4 Sep 2026 19:12:41 -0400 Subject: [PATCH] refac --- backend/open_webui/config.py | 1 + backend/open_webui/main.py | 2 + backend/open_webui/routers/auths.py | 32 +++- .../components/admin/Settings/General.svelte | 46 ++++-- .../admin/Settings/I18nSettings.svelte | 84 ++++++++++ .../admin/Settings/Interface/Banners.svelte | 2 +- src/lib/i18n/i18n.test.ts | 146 ++++++++++++++++++ src/lib/i18n/index.ts | 36 ++++- src/lib/i18n/locales/en-US/translation.json | 1 + src/lib/stores/index.ts | 2 + src/lib/utils/translationDictionary.ts | 48 ++++++ src/routes/+layout.svelte | 4 +- 12 files changed, 382 insertions(+), 22 deletions(-) create mode 100644 src/lib/components/admin/Settings/I18nSettings.svelte create mode 100644 src/lib/i18n/i18n.test.ts diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 15e5971b5c..ff77fbcf4e 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -3097,6 +3097,7 @@ DEFAULT_CONFIG = { 'ui.default_models': DEFAULT_MODELS, 'ui.default_pinned_models': DEFAULT_PINNED_MODELS, 'ui.default_interface_settings': DEFAULT_INTERFACE_SETTINGS, + 'ui.i18n': {}, 'ui.prompt_suggestions': DEFAULT_PROMPT_SUGGESTIONS, 'ui.prompt_suggestions_i18n': DEFAULT_PROMPT_SUGGESTIONS_I18N, 'ui.model_order_list': MODEL_ORDER_LIST, diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 726882afb0..a3bdf4f327 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -2260,6 +2260,7 @@ async def get_app_config(request: Request): 'ui.default_models', 'ui.default_pinned_models', 'ui.default_interface_settings', + 'ui.i18n', 'ui.prompt_suggestions', 'ui.prompt_suggestions_i18n', 'code_execution.engine', @@ -2284,6 +2285,7 @@ async def get_app_config(request: Request): 'name': app.state.WEBUI_NAME, 'version': VERSION, 'default_locale': str(DEFAULT_LOCALE), + 'i18n': config.get('ui.i18n') or {}, 'oauth': { # Hide providers (and thus the login buttons / auto-redirect) when OAuth # is disabled, without clearing the admin's provider configuration. diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index e0085b73fd..bd1697688c 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -78,7 +78,7 @@ from open_webui.utils.groups import apply_default_group_assignment from open_webui.utils.misc import parse_duration, validate_email_format from open_webui.utils.rate_limit import RateLimiter from open_webui.utils.redis import get_redis_client -from pydantic import BaseModel +from pydantic import BaseModel, StrictStr, field_validator from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -113,6 +113,7 @@ ADMIN_CONFIG_KEYS = { 'DEFAULT_USER_ROLE': 'ui.default_user_role', 'DEFAULT_GROUP_ID': 'ui.default_group_id', 'DEFAULT_INTERFACE_SETTINGS': 'ui.default_interface_settings', + 'I18N': 'ui.i18n', 'JWT_EXPIRES_IN': 'auth.jwt_expiry', 'ENABLE_COMMUNITY_SHARING': 'ui.enable_community_sharing', 'ENABLE_MESSAGE_RATING': 'ui.enable_message_rating', @@ -1216,6 +1217,7 @@ class AdminConfig(BaseModel): DEFAULT_USER_ROLE: str DEFAULT_GROUP_ID: str DEFAULT_INTERFACE_SETTINGS: dict | None = None + I18N: dict[str, dict[str, StrictStr]] | None = None JWT_EXPIRES_IN: str ENABLE_COMMUNITY_SHARING: bool ENABLE_MESSAGE_RATING: bool @@ -1236,10 +1238,38 @@ class AdminConfig(BaseModel): PENDING_USER_OVERLAY_CONTENT: str | None = None RESPONSE_WATERMARK: str | None = None + @field_validator('I18N') + @classmethod + def validate_i18n(cls, value): + if value is None: + raise ValueError('I18N must be a dictionary') + unsafe_keys = {'__proto__', 'prototype', 'constructor'} + + def placeholders(text): + return {match.strip() for match in re.findall(r'\{\{\s*-?\s*([^},]+)(?:,[^}]+)?\s*\}\}', text)} + + cleaned = {} + for locale, entries in value.items(): + if not locale.strip() or locale in unsafe_keys: + raise ValueError(f'Invalid language: {locale}') + translations = {} + for key, text in entries.items(): + if not key.strip() or key in unsafe_keys: + raise ValueError(f'Invalid translation key: {key}') + if text.strip(): + if placeholders(key) != placeholders(text): + raise ValueError(f'Interpolation placeholders do not match: {locale}: {key}') + translations[key] = text + if translations: + cleaned[locale] = translations + return cleaned + @router.post('/admin/config') async def update_admin_config(request: Request, form_data: AdminConfig, user=Depends(get_admin_user)): updates = config_updates(form_data.model_dump(), ADMIN_CONFIG_KEYS) + if 'I18N' not in form_data.model_fields_set: + updates.pop('ui.i18n', None) updates['ui.default_interface_settings'] = form_data.DEFAULT_INTERFACE_SETTINGS or {} updates['folders.max_file_count'] = int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else '' updates['automations.max_count'] = int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else '' diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index 92a7ddc605..b44e62f997 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -23,6 +23,9 @@ import AdminSettingRow from './AdminSettingRow.svelte'; import AdminSettingSection from './AdminSettingSection.svelte'; import Plus from '$lib/components/icons/Plus.svelte'; + import I18nSettings from './I18nSettings.svelte'; + import { updateI18n } from '$lib/i18n'; + import { entriesToI18n, i18nToEntries, type I18nEntry } from '$lib/utils/translationDictionary'; const i18n: any = getContext('i18n'); @@ -37,6 +40,8 @@ let adminConfig: any = null; let defaultInterfaceSettings: Record = {}; let showUserUiDefaults = false; + let uiI18nEntries: I18nEntry[] = []; + let saving = false; let banners: Banner[] = []; let bannerLocale = ''; @@ -73,24 +78,37 @@ }; const updateHandler = async () => { - adminConfig.DEFAULT_INTERFACE_SETTINGS = defaultInterfaceSettings; - - const res = await updateAdminConfig(localStorage.token, adminConfig); - - await updateBanners(); - - await config.set(await getBackendConfig()); - - if (res) { + if (saving) return; + saving = true; + try { + const cleaned = entriesToI18n(uiI18nEntries); + const res = await updateAdminConfig(localStorage.token, { + ...adminConfig, + DEFAULT_INTERFACE_SETTINGS: defaultInterfaceSettings, + I18N: cleaned + }); + if (!res) throw new Error($i18n.t('Failed to update settings')); + await updateI18n(res.I18N ?? cleaned); + await updateBanners(); + await config.set(await getBackendConfig()); saveHandler(); - } else { - toast.error($i18n.t('Failed to update settings')); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : Array.isArray(error) + ? error.map((entry) => entry.msg).join('\n') + : String(error) + ); + } finally { + saving = false; } }; onMount(async () => { adminConfig = await getAdminConfig(localStorage.token); defaultInterfaceSettings = getDefaultInterfaceSettings(); + uiI18nEntries = i18nToEntries(adminConfig.I18N ?? {}); banners = [...$_banners]; @@ -396,6 +414,9 @@ +
+ +
diff --git a/src/lib/components/admin/Settings/I18nSettings.svelte b/src/lib/components/admin/Settings/I18nSettings.svelte new file mode 100644 index 0000000000..2dac54d4be --- /dev/null +++ b/src/lib/components/admin/Settings/I18nSettings.svelte @@ -0,0 +1,84 @@ + + +
+
+
{$i18n.t('UI Translations')}
+
+ {#if entries.length}{/if} + + + +
+
+
+ {#each entries as entry, index (entry)} +
+ + + + + +
+ {/each} +
+
diff --git a/src/lib/components/admin/Settings/Interface/Banners.svelte b/src/lib/components/admin/Settings/Interface/Banners.svelte index 6d70798142..f22d4c9d93 100644 --- a/src/lib/components/admin/Settings/Interface/Banners.svelte +++ b/src/lib/components/admin/Settings/Interface/Banners.svelte @@ -37,7 +37,7 @@
{#each banners as banner, bannerIdx (banner.id)}