mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 12:25:20 +00:00
fature/support-multi-language- add header to identify selected language and send to backend for translation
This commit is contained in:
parent
876db8ec7f
commit
058de98975
11 changed files with 131 additions and 103 deletions
|
|
@ -2,7 +2,7 @@ import time
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
from aiocache import cached
|
from aiocache import cached
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
|
|
@ -34,6 +34,33 @@ log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def translate_model_title(model_name, accept_language: str) -> str:
|
||||||
|
"""
|
||||||
|
model_name: either a dict like {"de":"test DE","en":"test EN","fr":"test","it":"test IT"}
|
||||||
|
or a JSON string representation of such a dict
|
||||||
|
accept_language: string from header, e.g., "en-US", "de-CH", "fr"
|
||||||
|
"""
|
||||||
|
# if model_name is a string, try to parse as JSON
|
||||||
|
if isinstance(model_name, str):
|
||||||
|
try:
|
||||||
|
model_name = json.loads(model_name)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
# if it's not valid JSON (plain string), return the string as is
|
||||||
|
return model_name
|
||||||
|
|
||||||
|
|
||||||
|
# Handle None or empty accept_language
|
||||||
|
if not accept_language:
|
||||||
|
accept_language = 'en' # default to English if not provided
|
||||||
|
|
||||||
|
# normalize language code to primary subtag
|
||||||
|
lang = accept_language.split('-')[0] # "en-US" -> "en"
|
||||||
|
|
||||||
|
# return the translation if available, else fallback to 'de' or any available
|
||||||
|
return model_name.get(lang) or model_name.get('de') or next(iter(model_name.values()))
|
||||||
|
|
||||||
async def fetch_ollama_models(request: Request, user: UserModel = None):
|
async def fetch_ollama_models(request: Request, user: UserModel = None):
|
||||||
raw_ollama_models = await ollama.get_all_models(request, user=user)
|
raw_ollama_models = await ollama.get_all_models(request, user=user)
|
||||||
return [
|
return [
|
||||||
|
|
@ -158,7 +185,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||||
] # Ollama may return model ids in different formats (e.g., 'llama3' vs. 'llama3:7b')
|
] # Ollama may return model ids in different formats (e.g., 'llama3' vs. 'llama3:7b')
|
||||||
):
|
):
|
||||||
if custom_model.is_active:
|
if custom_model.is_active:
|
||||||
model["name"] = custom_model.name
|
model["name"] = translate_model_title(custom_model.name, request.headers.get("X-Language")),
|
||||||
model["info"] = custom_model.model_dump()
|
model["info"] = custom_model.model_dump()
|
||||||
|
|
||||||
# Set action_ids and filter_ids
|
# Set action_ids and filter_ids
|
||||||
|
|
@ -209,7 +236,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||||
models.append(
|
models.append(
|
||||||
{
|
{
|
||||||
"id": f"{custom_model.id}",
|
"id": f"{custom_model.id}",
|
||||||
"name": custom_model.name,
|
"name": translate_model_title(custom_model.name, request.headers.get("X-Language")),
|
||||||
"object": "model",
|
"object": "model",
|
||||||
"created": custom_model.created_at,
|
"created": custom_model.created_at,
|
||||||
"owned_by": owned_by,
|
"owned_by": owned_by,
|
||||||
|
|
|
||||||
33
src/hooks.client.ts
Normal file
33
src/hooks.client.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { temporaryChatEnabled } from '$lib/stores';
|
||||||
|
import { getI18nStore } from './lib/i18n';
|
||||||
|
|
||||||
|
export function customHeadersFetch() {
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
const i18nStore = getI18nStore();
|
||||||
|
|
||||||
|
window.fetch = async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||||
|
// get current values
|
||||||
|
const i18n = get(i18nStore);
|
||||||
|
|
||||||
|
// normalize headers to a plain object
|
||||||
|
const existingHeaders =
|
||||||
|
init.headers instanceof Headers
|
||||||
|
? Object.fromEntries(init.headers.entries())
|
||||||
|
: Array.isArray(init.headers)
|
||||||
|
? Object.fromEntries(init.headers)
|
||||||
|
: { ...(init.headers || {}) };
|
||||||
|
|
||||||
|
const newHeaders: Record<string, string> = { ...existingHeaders };
|
||||||
|
|
||||||
|
|
||||||
|
if (i18n?.language) {
|
||||||
|
newHeaders['X-Language'] = i18n.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalFetch(input, {
|
||||||
|
...init,
|
||||||
|
headers: newHeaders,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,34 +1,32 @@
|
||||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
export const getModels = async (token: string = '') => {
|
import { models, config, type Model } from '$lib/stores';
|
||||||
let error = null;
|
import { get } from 'svelte/store';
|
||||||
|
export const getModels = async (token: string = ''): Promise<Model[] | null> => {
|
||||||
|
const lang = get(config)?.default_locale || 'de';
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/models/`, {
|
const res = await fetch(`${WEBUI_API_BASE_URL}/models/`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
authorization: `Bearer ${token}`
|
'X-Language': lang,
|
||||||
}
|
Authorization: `Bearer ${token}`,
|
||||||
})
|
},
|
||||||
.then(async (res) => {
|
|
||||||
if (!res.ok) throw await res.json();
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
return json;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
error = err;
|
|
||||||
console.error(err);
|
|
||||||
return null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (!res.ok) throw await res.json();
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
const data = (await res.json()) as Model[];
|
||||||
|
models.set(data); // update global store so components react
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch models:', err);
|
||||||
|
models.set([]); // clear store on error
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getBaseModels = async (token: string = '') => {
|
export const getBaseModels = async (token: string = '') => {
|
||||||
|
|
|
||||||
|
|
@ -41,27 +41,6 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
let showMenu = false;
|
let showMenu = false;
|
||||||
$: langCode = $i18n.language?.split('-')[0] || 'de';
|
|
||||||
|
|
||||||
function getTranslatedLabel(label, langCode) {
|
|
||||||
if (!label) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If it's already an object, use it directly
|
|
||||||
const translations = typeof label === 'object' ? label : JSON.parse(label);
|
|
||||||
return (
|
|
||||||
translations[langCode] ||
|
|
||||||
translations.en ||
|
|
||||||
translations.de ||
|
|
||||||
translations.fr ||
|
|
||||||
translations.it ||
|
|
||||||
''
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// If parsing fails, return the original value
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -109,7 +88,7 @@
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Tooltip content={`${item.label} (${item.value})`} placement="top-start">
|
<Tooltip content={`${item.label} (${item.value})`} placement="top-start">
|
||||||
<div class="line-clamp-1">
|
<div class="line-clamp-1">
|
||||||
{getTranslatedLabel(item.label, langCode)}
|
{item.label}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -331,28 +331,6 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$: langCode = $i18n.language?.split('-')[0] || 'de';
|
|
||||||
|
|
||||||
function getTranslatedLabel(label, langCode) {
|
|
||||||
if (!label) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If it's already an object, use it directly
|
|
||||||
const translations = typeof label === 'object' ? label : JSON.parse(label);
|
|
||||||
return (
|
|
||||||
translations[langCode] ||
|
|
||||||
translations.en ||
|
|
||||||
translations.de ||
|
|
||||||
translations.fr ||
|
|
||||||
translations.it ||
|
|
||||||
''
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// If parsing fails, return the original value
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DropdownMenu.Root
|
<DropdownMenu.Root
|
||||||
|
|
@ -387,7 +365,7 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if selectedModel}
|
{#if selectedModel}
|
||||||
{getTranslatedLabel(selectedModel.label, langCode)}
|
{selectedModel.label}
|
||||||
{:else}
|
{:else}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -68,28 +68,6 @@
|
||||||
$: models = selectedModels.map((id) => $_models.find((m) => m.id === id));
|
$: models = selectedModels.map((id) => $_models.find((m) => m.id === id));
|
||||||
|
|
||||||
onMount(() => {});
|
onMount(() => {});
|
||||||
|
|
||||||
$: langCode = $i18n.language?.split('-')[0] || 'de';
|
|
||||||
|
|
||||||
function getTranslatedLabel(label, langCode) {
|
|
||||||
if (!label) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If it's already an object, use it directly
|
|
||||||
const translations = typeof label === 'object' ? label : JSON.parse(label);
|
|
||||||
return (
|
|
||||||
translations[langCode] ||
|
|
||||||
translations.en ||
|
|
||||||
translations.de ||
|
|
||||||
translations.fr ||
|
|
||||||
translations.it ||
|
|
||||||
''
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// If parsing fails, return the original value
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="m-auto w-full max-w-6xl px-2 @2xl:px-20 translate-y-6 py-24 text-center">
|
<div class="m-auto w-full max-w-6xl px-2 @2xl:px-20 translate-y-6 py-24 text-center">
|
||||||
|
|
@ -172,7 +150,7 @@
|
||||||
className=" flex items-center "
|
className=" flex items-center "
|
||||||
>
|
>
|
||||||
<span class="line-clamp-1">
|
<span class="line-clamp-1">
|
||||||
{getTranslatedLabel(models[selectedModelIdx]?.name, langCode)}
|
{models[selectedModelIdx]?.name}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
sessionStorage.model = JSON.stringify({
|
sessionStorage.model = JSON.stringify({
|
||||||
...model,
|
...model,
|
||||||
id: `${model.id}-clone`,
|
id: `${model.id}-clone`,
|
||||||
name: `${model.name} (Clone)`
|
name: `${getTranslatedLabel(model.name, langCode)} (Clone)`
|
||||||
});
|
});
|
||||||
goto('/workspace/models/create');
|
goto('/workspace/models/create');
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,8 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
name = info.name;
|
||||||
|
|
||||||
if (name === '') {
|
if (name === '') {
|
||||||
toast.error($i18n.t('Model Name is required.'));
|
toast.error($i18n.t('Model Name is required.'));
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import i18next from 'i18next';
|
||||||
import resourcesToBackend from 'i18next-resources-to-backend';
|
import resourcesToBackend from 'i18next-resources-to-backend';
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
import type { i18n as i18nType } from 'i18next';
|
import type { i18n as i18nType } from 'i18next';
|
||||||
import { writable } from 'svelte/store';
|
import { writable, type Writable } from 'svelte/store';
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
|
||||||
|
let i18nStore: Writable<any> | undefined;
|
||||||
|
|
||||||
const createI18nStore = (i18n: i18nType) => {
|
const createI18nStore = (i18n: i18nType) => {
|
||||||
const i18nWritable = writable(i18n);
|
const i18nWritable = writable(i18n);
|
||||||
|
|
@ -69,10 +72,25 @@ export const initI18n = (defaultLocale?: string | undefined) => {
|
||||||
|
|
||||||
const lang = i18next?.language || defaultLocale || 'en-US';
|
const lang = i18next?.language || defaultLocale || 'en-US';
|
||||||
document.documentElement.setAttribute('lang', lang);
|
document.documentElement.setAttribute('lang', lang);
|
||||||
|
|
||||||
|
if (!i18nStore) {
|
||||||
|
i18nStore = createI18nStore(i18next);
|
||||||
|
}
|
||||||
|
return i18nStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ---- accessors ----
|
||||||
|
export const getI18nStore = () => {
|
||||||
|
if (!i18nStore) {
|
||||||
|
// fallback dummy store until initI18n runs
|
||||||
|
i18nStore = writable(i18next);
|
||||||
|
}
|
||||||
|
return i18nStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
const i18n = createI18nStore(i18next);
|
|
||||||
const isLoadingStore = createIsLoadingStore(i18next);
|
|
||||||
|
|
||||||
export const getLanguages = async () => {
|
export const getLanguages = async () => {
|
||||||
const languages = (await import(`./locales/languages.json`)).default;
|
const languages = (await import(`./locales/languages.json`)).default;
|
||||||
|
|
@ -83,5 +101,16 @@ export const changeLanguage = (lang: string) => {
|
||||||
i18next.changeLanguage(lang);
|
i18next.changeLanguage(lang);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default i18n;
|
// ---- context support (optional) ----
|
||||||
export const isLoading = isLoadingStore;
|
export function initI18nContext() {
|
||||||
|
const store = writable({
|
||||||
|
locale: i18next.language ?? 'de',
|
||||||
|
t: (key: string) => key
|
||||||
|
});
|
||||||
|
setContext('i18n', store);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- exports ----
|
||||||
|
export const isLoading = createIsLoadingStore(i18next);
|
||||||
|
export default getI18nStore();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
|
|
||||||
import { WEBUI_VERSION } from '$lib/constants';
|
import { WEBUI_VERSION } from '$lib/constants';
|
||||||
import { compareVersion } from '$lib/utils';
|
import { compareVersion } from '$lib/utils';
|
||||||
|
import { customHeadersFetch } from '../../hooks.client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
config,
|
config,
|
||||||
|
|
@ -105,6 +106,7 @@
|
||||||
settings.set(localStorageSettings);
|
settings.set(localStorageSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
customHeadersFetch();
|
||||||
models.set(
|
models.set(
|
||||||
await getModels(
|
await getModels(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { spring } from 'svelte/motion';
|
import { spring } from 'svelte/motion';
|
||||||
import PyodideWorker from '$lib/workers/pyodide.worker?worker';
|
import PyodideWorker from '$lib/workers/pyodide.worker?worker';
|
||||||
|
import { customHeadersFetch } from '../hooks.client';
|
||||||
|
|
||||||
let loadingProgress = spring(0, {
|
let loadingProgress = spring(0, {
|
||||||
stiffness: 0.05
|
stiffness: 0.05
|
||||||
|
|
@ -557,6 +558,7 @@
|
||||||
// Initialize i18n even if we didn't get a backend config,
|
// Initialize i18n even if we didn't get a backend config,
|
||||||
// so `/error` can show something that's not `undefined`.
|
// so `/error` can show something that's not `undefined`.
|
||||||
|
|
||||||
|
customHeadersFetch();
|
||||||
initI18n(localStorage?.locale);
|
initI18n(localStorage?.locale);
|
||||||
if (!localStorage.locale) {
|
if (!localStorage.locale) {
|
||||||
const languages = await getLanguages();
|
const languages = await getLanguages();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue