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 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
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'; 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 = '') => {

View file

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

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> </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}

View file

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

View file

@ -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');
}; };

View file

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

View file

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

View file

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

View file

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