mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-07 08:27:05 +00:00
refac
This commit is contained in:
parent
858ab727d2
commit
67ac1a4e93
12 changed files with 382 additions and 22 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
|
|
|
|||
|
|
@ -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<string, any> = {};
|
||||
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 @@
|
|||
<Events />
|
||||
|
||||
<AdminSettingSection title={$i18n.t('UI')}>
|
||||
<fieldset id="ui-i18n-settings" disabled={saving} class="min-w-0">
|
||||
<I18nSettings bind:entries={uiI18nEntries} />
|
||||
</fieldset>
|
||||
<div class="shrink-0">
|
||||
<div class="flex items-center justify-between gap-4 py-0.5">
|
||||
<button
|
||||
|
|
@ -463,7 +484,7 @@
|
|||
<LanguageModeSelect bind:value={bannerLocale} className="w-fit" />
|
||||
{/if}
|
||||
<button
|
||||
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-900 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
class="flex size-6 items-center justify-center text-gray-400 dark:text-gray-600"
|
||||
type="button"
|
||||
aria-label={$i18n.t('Add banner')}
|
||||
on:click={() => {
|
||||
|
|
@ -499,6 +520,7 @@
|
|||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
|
|
|
|||
84
src/lib/components/admin/Settings/I18nSettings.svelte
Normal file
84
src/lib/components/admin/Settings/I18nSettings.svelte
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts">
|
||||
import { getContext, tick } from 'svelte';
|
||||
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import type { I18nEntry } from '$lib/utils/translationDictionary';
|
||||
import languages from '$lib/i18n/locales/languages.json';
|
||||
|
||||
const i18n = getContext<any>('i18n');
|
||||
export let entries: I18nEntry[] = [];
|
||||
let locale =
|
||||
languages.find((language) => language.code === $i18n.language)?.code ??
|
||||
languages.find((language) => language.code === $i18n.resolvedLanguage)?.code ??
|
||||
'en-US';
|
||||
let list: HTMLDivElement;
|
||||
const fieldClass =
|
||||
'block min-h-5 max-h-40 min-w-0 w-full resize-none bg-transparent text-[0.8125rem] leading-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 [field-sizing:content]';
|
||||
const add = async () => {
|
||||
if (!entries.length || entries.at(-1)?.content.trim()) {
|
||||
entries = [...entries, { content: '', i18n: {} }];
|
||||
}
|
||||
await tick();
|
||||
list.lastElementChild?.querySelector('textarea')?.focus();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex min-h-7 flex-wrap items-center justify-between gap-x-2 gap-y-1">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">{$i18n.t('UI Translations')}</div>
|
||||
<div class="ms-auto flex shrink-0 items-center gap-1">
|
||||
{#if entries.length}<LanguageModeSelect
|
||||
bind:value={locale}
|
||||
includeDefault={false}
|
||||
className="w-fit"
|
||||
/>{/if}
|
||||
<Tooltip content={$i18n.t('Add translation')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Add translation')}
|
||||
on:click={add}
|
||||
class="flex size-6 items-center justify-center text-gray-400 dark:text-gray-600"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div bind:this={list} class="flex flex-col gap-1.5">
|
||||
{#each entries as entry, index (entry)}
|
||||
<div
|
||||
class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_1.5rem] items-center gap-2 rounded-lg border border-gray-100/40 px-2 py-1 transition focus-within:border-blue-400 dark:border-gray-850/50 dark:focus-within:border-blue-500"
|
||||
>
|
||||
<textarea
|
||||
rows="1"
|
||||
bind:value={entry.content}
|
||||
aria-label={$i18n.t('Key')}
|
||||
placeholder={$i18n.t('Key')}
|
||||
class="{fieldClass} text-gray-500 dark:text-gray-400"
|
||||
></textarea>
|
||||
<textarea
|
||||
rows="1"
|
||||
value={entry.i18n[locale]?.content ?? ''}
|
||||
aria-label={$i18n.t('Value')}
|
||||
placeholder={$i18n.t('Value')}
|
||||
class="{fieldClass} text-gray-700 dark:text-gray-200"
|
||||
on:input={(event) => {
|
||||
entry.i18n = { ...entry.i18n, [locale]: { content: event.currentTarget.value } };
|
||||
}}
|
||||
></textarea>
|
||||
<Tooltip content={$i18n.t('Delete')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Delete')}
|
||||
on:click={() => (entries = entries.filter((_, i) => i !== index))}
|
||||
class="flex size-6 shrink-0 items-center justify-center text-gray-400 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<XMark className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
<div class="flex flex-col gap-1.5" use:init>
|
||||
{#each banners as banner, bannerIdx (banner.id)}
|
||||
<div
|
||||
class="flex items-start gap-1 rounded-lg border border-gray-100/40 bg-transparent px-2 py-1 transition focus-within:border-gray-300 dark:border-gray-850/50 dark:focus-within:border-gray-600"
|
||||
class="flex items-start gap-1 rounded-lg border border-gray-100/40 bg-transparent px-2 py-1 transition focus-within:border-blue-400 dark:border-gray-850/50 dark:focus-within:border-blue-500"
|
||||
id="banner-item-{banner.id}"
|
||||
>
|
||||
<Tooltip content={$i18n.t('Reorder')}>
|
||||
|
|
|
|||
146
src/lib/i18n/i18n.test.ts
Normal file
146
src/lib/i18n/i18n.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import i18next from 'i18next';
|
||||
import { get } from 'svelte/store';
|
||||
import store, { initI18n, loadBundledResource, updateI18n } from './index';
|
||||
import {
|
||||
validateDictionary,
|
||||
validateI18n,
|
||||
i18nToEntries,
|
||||
entriesToI18n
|
||||
} from '$lib/utils/translationDictionary';
|
||||
import {
|
||||
resolveLocalizedModelName,
|
||||
resolveLocalizedModelPromptSuggestions,
|
||||
resolveLocalizedPromptSuggestions,
|
||||
resolveLocalizedResource,
|
||||
resolveLocalizedString,
|
||||
localizeValvesSchema
|
||||
} from '$lib/utils/localizedContent';
|
||||
|
||||
beforeAll(async () => {
|
||||
await initI18n('en-US');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await updateI18n({});
|
||||
await i18next.changeLanguage('en-US');
|
||||
});
|
||||
|
||||
describe('UI i18n', () => {
|
||||
it('round-trips banner-style rows and preserves translations when originals are renamed', () => {
|
||||
const original = {
|
||||
'en-US': { Models: 'Assistants' },
|
||||
'de-DE': { Models: 'Assistenten', Save: 'Speichern' }
|
||||
};
|
||||
const entries = i18nToEntries(original);
|
||||
expect(entries.map((entry) => entry.content)).toEqual(['Models', 'Save']);
|
||||
expect(entriesToI18n(entries)).toEqual(original);
|
||||
entries[0].content = 'Tools';
|
||||
expect(entriesToI18n(entries)).toEqual({
|
||||
'en-US': { Tools: 'Assistants' },
|
||||
'de-DE': { Tools: 'Assistenten', Save: 'Speichern' }
|
||||
});
|
||||
expect(entriesToI18n(entries.slice(1))).toEqual({ 'de-DE': { Save: 'Speichern' } });
|
||||
});
|
||||
|
||||
it('keeps empty drafts local and rejects duplicate or missing originals without losing translations', () => {
|
||||
expect(
|
||||
entriesToI18n([
|
||||
{ content: '', i18n: {} },
|
||||
{ content: 'Models', i18n: {} }
|
||||
])
|
||||
).toEqual({});
|
||||
const entries = i18nToEntries({ 'en-US': { Models: 'Assistants' } });
|
||||
expect(() => entriesToI18n([...entries, { content: 'Models', i18n: {} }])).toThrow('Duplicate');
|
||||
entries[0].content = '';
|
||||
expect(() => entriesToI18n(entries)).toThrow('Original text');
|
||||
expect(entries[0].i18n['en-US'].content).toBe('Assistants');
|
||||
});
|
||||
|
||||
it('loads bootstrap overrides without modifying bundled resources', async () => {
|
||||
await initI18n('en-US', { 'en-US': { Models: 'Assistants' } });
|
||||
expect(i18next.t('Models')).toBe('Assistants');
|
||||
expect(i18next.t('Save')).toBe('Save');
|
||||
expect((await loadBundledResource('en-US')).Models).not.toBe('Assistants');
|
||||
});
|
||||
|
||||
it('updates loaded languages, notifies subscribers and restores deleted keys', async () => {
|
||||
let notifications = 0;
|
||||
const unsubscribe = store.subscribe(() => notifications++);
|
||||
await updateI18n({ 'en-US': { Models: 'Assistants', 'Custom old key': 'Legacy' } });
|
||||
expect(get(store).t('Models')).toBe('Assistants');
|
||||
await updateI18n({});
|
||||
expect(i18next.t('Models')).toBe('Models');
|
||||
expect(i18next.t('Custom old key')).toBe('Custom old key');
|
||||
expect(notifications).toBeGreaterThan(2);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('uses saved overrides for later language loads and retains pluralization and interpolation', async () => {
|
||||
await updateI18n({
|
||||
'fr-FR': { Models: 'Assistants FR' },
|
||||
'en-US': {
|
||||
'{{count}} files_one': '{{count}} document',
|
||||
'{{count}} files_other': '{{count}} documents',
|
||||
'Hello {{name}}': 'Welcome {{name}}'
|
||||
}
|
||||
});
|
||||
await i18next.changeLanguage('fr');
|
||||
expect(i18next.t('Models')).toBe('Assistants FR');
|
||||
await i18next.changeLanguage('en-US');
|
||||
expect(i18next.t('{{count}} files', { count: 1 })).toBe('1 document');
|
||||
expect(i18next.t('{{count}} files', { count: 2 })).toBe('2 documents');
|
||||
expect(i18next.t('Hello {{name}}', { name: 'Sam' })).toBe('Welcome Sam');
|
||||
await updateI18n({});
|
||||
await i18next.changeLanguage('fr');
|
||||
expect(i18next.t('Models')).toBe((await loadBundledResource('fr-FR')).Models);
|
||||
});
|
||||
|
||||
it('validates imports and removes empty overrides without trimming meaningful text', () => {
|
||||
expect(validateI18n({ 'en-US': { Models: ' Assistants ', Save: ' ' }, de: {} })).toEqual({
|
||||
'en-US': { Models: ' Assistants ' }
|
||||
});
|
||||
expect(() => validateDictionary({ 'Hello {{name}}': 'Hi {{other}}' })).toThrow('placeholders');
|
||||
expect(() => validateDictionary({ Models: 2 })).toThrow('string');
|
||||
expect(() => validateDictionary([])).toThrow('JSON object');
|
||||
expect(() => validateI18n(JSON.parse('{"__proto__":{"Models":"Bad"}}'))).toThrow('language');
|
||||
expect(() => validateDictionary(JSON.parse('{"constructor":"Bad"}'))).toThrow('key');
|
||||
expect(validateDictionary({ 'Old key': 'Retained' })).toEqual({ 'Old key': 'Retained' });
|
||||
});
|
||||
|
||||
it('keeps resource localization independent of built-in UI overrides', async () => {
|
||||
await updateI18n({
|
||||
'en-US': { Models: 'Assistants', name: 'Not a resource name', content: 'Not banner content' }
|
||||
});
|
||||
const meta = {
|
||||
i18n: {
|
||||
'en-US': { name: 'Resource name', suggestion_prompts: [], 'valves.mode.title': 'Speed' }
|
||||
}
|
||||
};
|
||||
expect(resolveLocalizedModelName({ name: 'Default model', meta }, 'en-US')).toBe(
|
||||
'Resource name'
|
||||
);
|
||||
expect(resolveLocalizedResource({ name: 'Default tool', meta }, 'en-US')).toBe('Resource name');
|
||||
expect(resolveLocalizedModelPromptSuggestions({ meta }, 'en-US')).toEqual([]);
|
||||
expect(
|
||||
resolveLocalizedPromptSuggestions([], { 'en-US': { suggestion_prompts: [] } }, 'en-US')
|
||||
).toEqual([]);
|
||||
expect(
|
||||
resolveLocalizedString(
|
||||
'Default banner',
|
||||
{ 'en-US': { content: 'Banner message' } },
|
||||
'en-US',
|
||||
'content'
|
||||
)
|
||||
).toBe('Banner message');
|
||||
const schema = { properties: { mode: { title: 'Mode', enum: ['fast'], default: 'fast' } } };
|
||||
const localized = localizeValvesSchema(schema, 'en-US', meta);
|
||||
expect(localized.properties.mode).toEqual({
|
||||
title: 'Speed',
|
||||
description: '',
|
||||
enum: ['fast'],
|
||||
default: 'fast'
|
||||
});
|
||||
expect(schema.properties.mode.title).toBe('Mode');
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,32 @@ import resourcesToBackend from 'i18next-resources-to-backend';
|
|||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import type { i18n as i18nType } from 'i18next';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { I18nOverrides } from '$lib/utils/translationDictionary';
|
||||
|
||||
let overrides: I18nOverrides = {};
|
||||
|
||||
export const loadBundledResource = async (language: string): Promise<Record<string, string>> =>
|
||||
(await import(`./locales/${language}/translation.json`)).default;
|
||||
|
||||
const loadResource = async (language: string) => ({
|
||||
...(await loadBundledResource(language)),
|
||||
...overrides[language]
|
||||
});
|
||||
|
||||
export const updateI18n = async (value: I18nOverrides = {}) => {
|
||||
overrides = value;
|
||||
const resources = await Promise.all(
|
||||
Object.keys(i18next.store?.data ?? {}).map(async (language) => ({
|
||||
language,
|
||||
resource: await loadResource(language)
|
||||
}))
|
||||
);
|
||||
for (const { language, resource } of resources) {
|
||||
i18next.removeResourceBundle(language, 'translation');
|
||||
i18next.addResourceBundle(language, 'translation', resource);
|
||||
}
|
||||
i18n.set(i18next);
|
||||
};
|
||||
|
||||
const createI18nStore = (i18n: i18nType) => {
|
||||
const i18nWritable = writable(i18n);
|
||||
|
|
@ -40,16 +66,14 @@ const createIsLoadingStore = (i18n: i18nType) => {
|
|||
return isLoading;
|
||||
};
|
||||
|
||||
export const initI18n = (defaultLocale?: string | undefined) => {
|
||||
export const initI18n = (defaultLocale?: string, value: I18nOverrides = {}) => {
|
||||
overrides = value;
|
||||
const detectionOrder = defaultLocale
|
||||
? ['querystring', 'localStorage']
|
||||
: ['querystring', 'localStorage', 'navigator'];
|
||||
const fallbackDefaultLocale = defaultLocale ? [defaultLocale] : ['en-US'];
|
||||
|
||||
const loadResource = (language: string, namespace: string) =>
|
||||
import(`./locales/${language}/${namespace}.json`);
|
||||
|
||||
i18next
|
||||
return i18next
|
||||
.use(resourcesToBackend(loadResource))
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
|
|
@ -83,7 +107,7 @@ export const getLanguages = async () => {
|
|||
};
|
||||
export const changeLanguage = (lang: string) => {
|
||||
document.documentElement.setAttribute('lang', lang);
|
||||
i18next.changeLanguage(lang);
|
||||
return i18next.changeLanguage(lang);
|
||||
};
|
||||
|
||||
export default i18n;
|
||||
|
|
|
|||
|
|
@ -2920,6 +2920,7 @@
|
|||
"Type your answer": "",
|
||||
"Uh-oh! There was an issue with the response.": "",
|
||||
"UI": "",
|
||||
"UI Translations": "",
|
||||
"UI Scale": "",
|
||||
"Unarchive All": "",
|
||||
"Unarchive Chat": "",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { ModelConfig } from '$lib/apis';
|
|||
import type { Banner } from '$lib/types';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import type { AudioQueue } from '$lib/utils/audio';
|
||||
import type { I18nOverrides } from '$lib/utils/translationDictionary';
|
||||
|
||||
import emojiShortCodes from '$lib/emoji-shortcodes.json';
|
||||
|
||||
|
|
@ -329,6 +330,7 @@ type Config = {
|
|||
name: string;
|
||||
version: string;
|
||||
default_locale: string;
|
||||
i18n?: I18nOverrides;
|
||||
default_models: string;
|
||||
default_pinned_models?: string | null;
|
||||
default_prompt_suggestions: PromptSuggestion[];
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const validateDictionary = (
|
|||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new Error('Expected a JSON object of translation keys and strings.');
|
||||
for (const [key, text] of Object.entries(value)) {
|
||||
if (!key.trim() || unsafeKeys.has(key)) throw new Error(`Invalid translation key: ${key}`);
|
||||
if (typeof text !== 'string') throw new Error(`Translation must be a string: ${key}`);
|
||||
if (
|
||||
text.trim() &&
|
||||
|
|
@ -23,3 +24,50 @@ export const validateDictionary = (
|
|||
}
|
||||
return value as Record<string, string>;
|
||||
};
|
||||
|
||||
export const validateI18n = (value: I18nOverrides): I18nOverrides => {
|
||||
const cleaned: I18nOverrides = {};
|
||||
for (const [locale, entries] of Object.entries(value)) {
|
||||
if (!locale.trim() || unsafeKeys.has(locale)) throw new Error(`Invalid language: ${locale}`);
|
||||
const dictionary = validateDictionary(entries);
|
||||
const nonempty = Object.fromEntries(
|
||||
Object.entries(dictionary).filter(([, text]) => text.trim())
|
||||
);
|
||||
if (Object.keys(nonempty).length) cleaned[locale] = nonempty;
|
||||
}
|
||||
return cleaned;
|
||||
};
|
||||
export type I18nOverrides = Record<string, Record<string, string>>;
|
||||
export type I18nEntry = { content: string; i18n: I18nOverrides };
|
||||
|
||||
export const i18nToEntries = (value: I18nOverrides): I18nEntry[] => {
|
||||
const entries = new Map<string, I18nEntry>();
|
||||
for (const [locale, dictionary] of Object.entries(value)) {
|
||||
for (const [content, translation] of Object.entries(dictionary)) {
|
||||
if (!entries.has(content)) entries.set(content, { content, i18n: {} });
|
||||
entries.get(content)!.i18n[locale] = { content: translation };
|
||||
}
|
||||
}
|
||||
return [...entries.values()];
|
||||
};
|
||||
|
||||
export const entriesToI18n = (entries: I18nEntry[]): I18nOverrides => {
|
||||
const value: I18nOverrides = {};
|
||||
const seen = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const translations = Object.entries(entry.i18n).filter(([, text]) => text.content.trim());
|
||||
if (!entry.content.trim()) {
|
||||
if (translations.length) throw new Error('Original text is required for translated rows.');
|
||||
continue;
|
||||
}
|
||||
if (seen.has(entry.content)) throw new Error(`Duplicate original text: ${entry.content}`);
|
||||
seen.add(entry.content);
|
||||
validateDictionary({ [entry.content]: '' });
|
||||
for (const [locale, translation] of translations) {
|
||||
value[locale] = { ...value[locale], [entry.content]: translation.content };
|
||||
}
|
||||
}
|
||||
return validateI18n(value);
|
||||
};
|
||||
|
||||
const unsafeKeys = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
|
|
|
|||
|
|
@ -1237,7 +1237,7 @@
|
|||
// Initialize i18n even if we didn't get a backend config,
|
||||
// so `/error` can show something that's not `undefined`.
|
||||
|
||||
initI18n(localStorage?.locale);
|
||||
await initI18n(localStorage?.locale, backendConfig?.i18n ?? {});
|
||||
if (!localStorage.locale) {
|
||||
const languages = await getLanguages();
|
||||
const browserLanguages = navigator.languages
|
||||
|
|
@ -1246,7 +1246,7 @@
|
|||
const lang = backendConfig?.default_locale
|
||||
? backendConfig.default_locale
|
||||
: bestMatchingLanguage(languages, browserLanguages, 'en-US');
|
||||
changeLanguage(lang);
|
||||
await changeLanguage(lang);
|
||||
dayjs.locale(lang);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue