fature/support-multi-language- add header to identify selected language and send to backend for translation

This commit is contained in:
u80861711 2025-08-25 19:30:27 +02:00
parent 876db8ec7f
commit 058de98975
11 changed files with 131 additions and 103 deletions

View file

@ -2,7 +2,7 @@ import time
import logging
import asyncio
import sys
import json
from aiocache import cached
from fastapi import Request
@ -34,6 +34,33 @@ log = logging.getLogger(__name__)
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):
raw_ollama_models = await ollama.get_all_models(request, user=user)
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')
):
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()
# Set action_ids and filter_ids
@ -209,7 +236,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
models.append(
{
"id": f"{custom_model.id}",
"name": custom_model.name,
"name": translate_model_title(custom_model.name, request.headers.get("X-Language")),
"object": "model",
"created": custom_model.created_at,
"owned_by": owned_by,

33
src/hooks.client.ts Normal file
View 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,
});
};
}

View file

@ -1,34 +1,32 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const getModels = async (token: string = '') => {
let error = null;
import { models, config, type Model } from '$lib/stores';
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/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
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;
'X-Language': lang,
Authorization: `Bearer ${token}`,
},
});
if (error) {
throw error;
}
if (!res.ok) throw await res.json();
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 = '') => {

View file

@ -41,27 +41,6 @@
};
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>
<button
@ -109,7 +88,7 @@
<div class="flex items-center">
<Tooltip content={`${item.label} (${item.value})`} placement="top-start">
<div class="line-clamp-1">
{getTranslatedLabel(item.label, langCode)}
{item.label}
</div>
</Tooltip>
</div>

View file

@ -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>
<DropdownMenu.Root
@ -387,7 +365,7 @@
}}
>
{#if selectedModel}
{getTranslatedLabel(selectedModel.label, langCode)}
{selectedModel.label}
{:else}
{placeholder}
{/if}

View file

@ -68,28 +68,6 @@
$: models = selectedModels.map((id) => $_models.find((m) => m.id === id));
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>
<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 "
>
<span class="line-clamp-1">
{getTranslatedLabel(models[selectedModelIdx]?.name, langCode)}
{models[selectedModelIdx]?.name}
</span>
</Tooltip>
{:else}

View file

@ -97,7 +97,7 @@
sessionStorage.model = JSON.stringify({
...model,
id: `${model.id}-clone`,
name: `${model.name} (Clone)`
name: `${getTranslatedLabel(model.name, langCode)} (Clone)`
});
goto('/workspace/models/create');
};

View file

@ -123,6 +123,8 @@
return;
}
name = info.name;
if (name === '') {
toast.error($i18n.t('Model Name is required.'));
loading = false;

View file

@ -2,7 +2,10 @@ import i18next from 'i18next';
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 { writable, type Writable } from 'svelte/store';
import { setContext } from 'svelte';
let i18nStore: Writable<any> | undefined;
const createI18nStore = (i18n: i18nType) => {
const i18nWritable = writable(i18n);
@ -69,10 +72,25 @@ export const initI18n = (defaultLocale?: string | undefined) => {
const lang = i18next?.language || defaultLocale || 'en-US';
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 () => {
const languages = (await import(`./locales/languages.json`)).default;
@ -83,5 +101,16 @@ export const changeLanguage = (lang: string) => {
i18next.changeLanguage(lang);
};
export default i18n;
export const isLoading = isLoadingStore;
// ---- context support (optional) ----
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();

View file

@ -21,6 +21,7 @@
import { WEBUI_VERSION } from '$lib/constants';
import { compareVersion } from '$lib/utils';
import { customHeadersFetch } from '../../hooks.client';
import {
config,
@ -105,6 +106,7 @@
settings.set(localStorageSettings);
}
customHeadersFetch();
models.set(
await getModels(
localStorage.token,

View file

@ -2,6 +2,7 @@
import { io } from 'socket.io-client';
import { spring } from 'svelte/motion';
import PyodideWorker from '$lib/workers/pyodide.worker?worker';
import { customHeadersFetch } from '../hooks.client';
let loadingProgress = spring(0, {
stiffness: 0.05
@ -557,6 +558,7 @@
// Initialize i18n even if we didn't get a backend config,
// so `/error` can show something that's not `undefined`.
customHeadersFetch();
initI18n(localStorage?.locale);
if (!localStorage.locale) {
const languages = await getLanguages();