Compare commits

...

18 commits

Author SHA1 Message Date
alvarellos
f279af5915
Merge 8621445baf into ae47101dc6 2025-12-10 17:18:53 +01:00
Diego
8621445baf removed duplicated suggestion promt line 2025-12-09 10:45:30 +01:00
Diego
c9a8da6778 updated to last version available Dec 8.12.2025 2025-12-08 10:25:14 +01:00
Diego
5455298630 for some strange reason version was incorrect, put it back to 0.6.40 2025-11-25 16:28:51 +01:00
Diego
07db21ac80 place back package-json as it was, fix issues from last merge 2025-11-25 16:02:12 +01:00
Diego
0376a661b7 Merge remote-tracking branch 'upstream/dev' into feature/Support-multi-language-title-and-prompt-in-workspace 2025-11-25 15:31:46 +01:00
Diego
890acf8f88 added version 0.6.38 2025-11-25 09:41:24 +01:00
Diego
b6947c9813 update with upstream dev 21-11-2025 2025-11-21 09:27:22 +01:00
Diego
c23ea22904 return files which were erased by conda environment 2025-11-21 08:55:16 +01:00
Diego
c5a0711509 updated to version 0.6.36 hopefully for the last time that I need to do this 2025-11-20 18:22:31 +01:00
u80861711
3990330e88 translation also for info name of the model api endpoint 2025-09-05 13:45:33 +02:00
u80861711
6dddd5d529 fix sync in between text area and modal 2025-09-04 16:39:47 +02:00
u80861711
fa33c94944 use the configuration languages for all Title Models, Suggested Prompts and Banners. So this is now ready for translations globally 2025-09-01 17:11:16 +02:00
u80861711
cb86c01fb9 Merge branch 'main' into feature/Support-multi-language-title-and-prompt-in-workspace 2025-08-28 12:57:56 +02:00
u80861711
a280f65249 add the configuration for selecting the languages codes son they can be used for translations 2025-08-27 19:18:05 +02:00
u80861711
058de98975 fature/support-multi-language- add header to identify selected language and send to backend for translation 2025-08-25 19:30:27 +02:00
u80861711
876db8ec7f merge version v0.6.25 2025-08-25 18:17:50 +02:00
u80861711
80af49d8d5 Support translations for title and prompt in workspace 2025-07-18 16:36:46 +02:00
18 changed files with 1160 additions and 310 deletions

View file

@ -1886,6 +1886,12 @@ ENABLE_AUTOCOMPLETE_GENERATION = PersistentConfig(
os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "False").lower() == "true", os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "False").lower() == "true",
) )
TRANSLATION_LANGUAGES = PersistentConfig(
"TRANSLATION_LANGUAGES",
"translation_languages",
os.getenv("TRANSLATION_LANGUAGES", "en").split(","),
)
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig( AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig(
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH", "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH",
"task.autocomplete.input_max_length", "task.autocomplete.input_max_length",

View file

@ -432,6 +432,7 @@ from open_webui.config import (
QUERY_GENERATION_PROMPT_TEMPLATE, QUERY_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE, AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
TRANSLATION_LANGUAGES,
AppConfig, AppConfig,
reset_config, reset_config,
) )
@ -1189,6 +1190,7 @@ app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = ENABLE_AUTOCOMPLETE_GENERATION
app.state.config.ENABLE_TAGS_GENERATION = ENABLE_TAGS_GENERATION app.state.config.ENABLE_TAGS_GENERATION = ENABLE_TAGS_GENERATION
app.state.config.ENABLE_TITLE_GENERATION = ENABLE_TITLE_GENERATION app.state.config.ENABLE_TITLE_GENERATION = ENABLE_TITLE_GENERATION
app.state.config.ENABLE_FOLLOW_UP_GENERATION = ENABLE_FOLLOW_UP_GENERATION app.state.config.ENABLE_FOLLOW_UP_GENERATION = ENABLE_FOLLOW_UP_GENERATION
app.state.config.TRANSLATION_LANGUAGES = TRANSLATION_LANGUAGES
app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE
@ -1864,6 +1866,7 @@ async def get_app_config(request: Request):
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS, "enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, "enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION, "enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
"translation_languages": app.state.config.TRANSLATION_LANGUAGES,
**( **(
{ {
"enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL, "enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,

View file

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional, List
import logging import logging
import re import re
@ -69,6 +69,7 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)):
"ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION, "ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
"QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE, "QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
"TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
"TRANSLATION_LANGUAGES": request.app.state.config.TRANSLATION_LANGUAGES,
"VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE, "VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
} }
@ -89,6 +90,7 @@ class TaskConfigForm(BaseModel):
ENABLE_RETRIEVAL_QUERY_GENERATION: bool ENABLE_RETRIEVAL_QUERY_GENERATION: bool
QUERY_GENERATION_PROMPT_TEMPLATE: str QUERY_GENERATION_PROMPT_TEMPLATE: str
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: str TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: str
TRANSLATION_LANGUAGES: Optional[List[str]] = []
VOICE_MODE_PROMPT_TEMPLATE: Optional[str] VOICE_MODE_PROMPT_TEMPLATE: Optional[str]
@ -139,6 +141,10 @@ async def update_task_config(
form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
) )
request.app.state.config.TRANSLATION_LANGUAGES = (
form_data.TRANSLATION_LANGUAGES
)
request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = ( request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = (
form_data.VOICE_MODE_PROMPT_TEMPLATE form_data.VOICE_MODE_PROMPT_TEMPLATE
) )
@ -159,6 +165,7 @@ async def update_task_config(
"ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION, "ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
"QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE, "QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
"TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
"TRANSLATION_LANGUAGES": request.app.state.config.TRANSLATION_LANGUAGES,
"VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE, "VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
} }

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
@ -37,6 +37,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 [
@ -160,6 +187,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
0 0
] # 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')
): ):
# This is what is answered in the info part
custom_model.name = translate_model_title(custom_model.name, request.headers.get("X-Language")),
if custom_model.is_active: if custom_model.is_active:
model["name"] = custom_model.name model["name"] = custom_model.name
model["info"] = custom_model.model_dump() model["info"] = custom_model.model_dump()
@ -237,6 +266,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
if "filterIds" in meta: if "filterIds" in meta:
filter_ids.extend(meta["filterIds"]) filter_ids.extend(meta["filterIds"])
# Apply translation to the model name
model["name"] = translate_model_title(custom_model.name, request.headers.get("X-Language"))
model["current_language"] = model["name"]
model["action_ids"] = action_ids model["action_ids"] = action_ids
model["filter_ids"] = filter_ids model["filter_ids"] = filter_ids

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,4 +1,30 @@
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
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 || 'en';
try {
const res = await fetch(`${WEBUI_API_BASE_URL}/models/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Language': lang,
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw await res.json();
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 getModelItems = async ( export const getModelItems = async (
token: string = '', token: string = '',
@ -9,6 +35,7 @@ export const getModelItems = async (
direction, direction,
page page
) => { ) => {
const lang = get(config)?.default_locale || 'en';
let error = null; let error = null;
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@ -61,12 +88,13 @@ export const getModelItems = async (
export const getModelTags = async (token: string = '') => { export const getModelTags = async (token: string = '') => {
let error = null; let error = null;
const lang = get(config)?.default_locale || 'en';
const res = await fetch(`${WEBUI_API_BASE_URL}/models/tags`, { const res = await fetch(`${WEBUI_API_BASE_URL}/models/tags`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Language': lang,
authorization: `Bearer ${token}` authorization: `Bearer ${token}`
} }
}) })

View file

@ -23,6 +23,8 @@
import Banners from './Interface/Banners.svelte'; import Banners from './Interface/Banners.svelte';
import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte'; import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte';
import LangPicker from './LangPicker.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -43,6 +45,7 @@
ENABLE_RETRIEVAL_QUERY_GENERATION: true, ENABLE_RETRIEVAL_QUERY_GENERATION: true,
QUERY_GENERATION_PROMPT_TEMPLATE: '', QUERY_GENERATION_PROMPT_TEMPLATE: '',
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: '', TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: '',
TRANSLATION_LANGUAGES: [],
VOICE_MODE_PROMPT_TEMPLATE: '' VOICE_MODE_PROMPT_TEMPLATE: ''
}; };
@ -50,6 +53,12 @@
let banners: Banner[] = []; let banners: Banner[] = [];
const updateInterfaceHandler = async () => { const updateInterfaceHandler = async () => {
// Trim any spaces from translation languages before saving
if (taskConfig.TRANSLATION_LANGUAGES && Array.isArray(taskConfig.TRANSLATION_LANGUAGES)) {
taskConfig.TRANSLATION_LANGUAGES = taskConfig.TRANSLATION_LANGUAGES
.map((lang: string) => lang.trim())
.filter((lang: string) => lang !== '');
}
taskConfig = await updateTaskConfig(localStorage.token, taskConfig); taskConfig = await updateTaskConfig(localStorage.token, taskConfig);
promptSuggestions = promptSuggestions.filter((p) => p.content !== ''); promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
@ -442,7 +451,7 @@
id: uuidv4(), id: uuidv4(),
type: '', type: '',
title: '', title: '',
content: '', content: JSON.stringify({ de: '', en: '', fr: '', it: '' }),
dismissible: true, dismissible: true,
timestamp: Math.floor(Date.now() / 1000) timestamp: Math.floor(Date.now() / 1000)
} }
@ -467,6 +476,14 @@
</div> </div>
{#if $user?.role === 'admin'} {#if $user?.role === 'admin'}
<div class="flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Select languages for translations')}
</div>
<LangPicker bind:selected={taskConfig.TRANSLATION_LANGUAGES} />
</div>
<div class=" space-y-3">
<PromptSuggestions bind:promptSuggestions /> <PromptSuggestions bind:promptSuggestions />
{#if promptSuggestions.length > 0} {#if promptSuggestions.length > 0}
@ -474,8 +491,8 @@
{$i18n.t('Adjusting these settings will apply changes universally to all users.')} {$i18n.t('Adjusting these settings will apply changes universally to all users.')}
</div> </div>
{/if} {/if}
{/if}
</div> </div>
{/if}
</div> </div>
<div class="flex justify-end text-sm font-medium"> <div class="flex justify-end text-sm font-medium">

View file

@ -4,58 +4,206 @@
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte'; import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import XMark from '$lib/components/icons/XMark.svelte'; import XMark from '$lib/components/icons/XMark.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { config } from '$lib/stores';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let banners = []; export let banners: any[] = [];
let sortable = null; let sortable: any = null;
let bannerListElement = null; let bannerListElement: HTMLDivElement | null = null;
const positionChangeHandler = () => { // reactive UI language code
const bannerIdOrder = Array.from(bannerListElement.children).map((child) => $: langCode = $i18n?.language?.split('-')[0] || 'de';
child.id.replace('banner-item-', '')
);
// Sort the banners array based on the new order // dynamic languages from config
banners = bannerIdOrder.map((id) => { $: LANGS = Array.isArray($config.features.translation_languages)
const index = banners.findIndex((banner) => banner.id === id); ? [...new Set([...$config.features.translation_languages, langCode])]
return banners[index]; : [langCode, 'de'];
});
};
const classNames: Record<string, string> = { // contentObjs stores parsed content objects keyed by banner.id
info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ', let contentObjs: Record<string, Record<string, string>> = {};
success: 'bg-green-500/20 text-green-700 dark:text-green-200',
warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
error: 'bg-red-500/20 text-red-700 dark:text-red-200'
};
$: if (banners) { // Create a helper function to get/set content safely
init(); function getContent(bannerId: string, lang: string): string {
ensureContentStructure(bannerId);
return contentObjs[bannerId][lang] || '';
} }
const init = () => { function setContent(bannerId: string, lang: string, value: string) {
if (sortable) { ensureContentStructure(bannerId);
sortable.destroy(); contentObjs[bannerId][lang] = value;
contentObjs = { ...contentObjs };
// Update the corresponding banner
const bannerIndex = banners.findIndex(b => b.id === bannerId);
if (bannerIndex !== -1) {
banners[bannerIndex].content = safeStringify(contentObjs[bannerId]);
banners = [...banners];
// Sync to modal if open
if (showBannerModal && editingBannerIndex === bannerIndex) {
newBanner.content[lang] = value;
newBanner = { ...newBanner };
}
}
} }
function ensureContentStructure(bannerId: string) {
if (!contentObjs[bannerId]) {
const banner = banners.find(b => b.id === bannerId);
contentObjs[bannerId] = banner ? parseContentToObj(banner.content) : {};
}
// Ensure all required languages exist
const allLangs = [...new Set([...LANGS, langCode])];
for (const lang of allLangs) {
if (!contentObjs[bannerId][lang]) {
contentObjs[bannerId][lang] = '';
}
}
}
// Initialize/Sync contentObjs with banners
$: if (banners && banners.length > 0) {
for (const b of banners) {
if (!b) continue;
const id = b.id ?? (b.id = crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()));
ensureContentStructure(id);
// Sync from banner.content if it has changed
const currentString = safeStringify(contentObjs[id]);
const incomingString = typeof b.content === 'string'
? b.content
: safeStringify(parseContentToObj(b.content));
if (incomingString !== currentString && incomingString !== '{}') {
contentObjs[id] = parseContentToObj(b.content);
ensureContentStructure(id); // Re-ensure after parsing
}
}
// remove entries for banners that no longer exist
const ids = new Set(banners.map((b) => b.id));
for (const key of Object.keys(contentObjs)) {
if (!ids.has(key)) delete contentObjs[key];
}
initSortable();
}
function parseContentToObj(content: any) {
let parsed: Record<string, string> = {};
try {
parsed = typeof content === 'string' ? JSON.parse(content) : { ...content };
} catch {
parsed = { de: content || '' };
}
// keep current languages and check that LANGS are included
const allLangs = [...new Set([...LANGS, langCode, ...Object.keys(parsed)])];
for (const lang of allLangs) {
if (parsed[lang] == null) parsed[lang] = '';
}
return parsed;
}
function safeStringify(obj: any) {
try { return JSON.stringify(obj); }
catch { return '{}'; }
}
function initSortable() {
if (sortable) { try { sortable.destroy(); } catch {} sortable = null; }
if (bannerListElement) { if (bannerListElement) {
sortable = new Sortable(bannerListElement, { sortable = Sortable.create(bannerListElement, {
animation: 150, animation: 150,
handle: '.item-handle', handle: '.item-handle',
onUpdate: async (event) => { onUpdate: () => {
positionChangeHandler(); const order = Array.from(bannerListElement!.children)
.map(ch => (ch as HTMLElement).id.replace('banner-item-', ''));
banners = order.map(id => banners.find(b => b.id === id));
} }
}); });
} }
}
let showBannerModal = false;
let editingBannerIndex: number | null = null;
let newBanner: { id: string; content: Record<string, string>; workspaces: string[] } = { id: '', content: {}, workspaces: [] };
function openEditModal(idx: number) {
editingBannerIndex = idx;
const b = banners[idx];
if (!b) return;
const id = b.id;
const workspaces = b.workspaces || [];
ensureContentStructure(id);
// Copy current contentObjs to newBanner
newBanner = {
id,
content: JSON.parse(JSON.stringify(contentObjs[id])), // Deep copy of current state
workspaces: [...workspaces]
}; };
showBannerModal = true;
}
function syncModalToInline(changedLang: string) {
if (editingBannerIndex !== null) {
const id = banners[editingBannerIndex].id;
ensureContentStructure(id);
contentObjs[id][changedLang] = newBanner.content[changedLang] || '';
contentObjs = { ...contentObjs };
// Update banner content
banners[editingBannerIndex].content = safeStringify(contentObjs[id]);
banners = [...banners];
}
}
function closeModal() {
showBannerModal = false;
editingBannerIndex = null;
newBanner = { id: '', content: Object.fromEntries(LANGS.map(l => [l, ''])), workspaces:[] };
}
function saveModal() {
const any = Object.values(newBanner.content).some(v => v && v.trim() !== '');
if (!any) {
alert('At least one translation is required.');
return;
}
if (!newBanner.content.de?.trim()) {
const first = Object.values(newBanner.content).find(v => v.trim() !== '');
if (first) newBanner.content.de = first;
}
if (editingBannerIndex != null) {
banners[editingBannerIndex].content = safeStringify(newBanner.content);
banners[editingBannerIndex].workspaces = newBanner.workspaces;
contentObjs[newBanner.id] = { ...newBanner.content };
contentObjs = { ...contentObjs };
banners = [...banners];
}
closeModal();
}
</script> </script>
<div class=" flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}> <!-- Draggable banners -->
<div class="flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
{#each banners as banner, bannerIdx (banner.id)} {#each banners as banner, bannerIdx (banner.id)}
<div class=" flex justify-between items-start -ml-1" id="banner-item-{banner.id}"> <div class="flex justify-between items-start -ml-1" id={"banner-item-" + banner.id}>
<EllipsisVertical className="size-4 cursor-move item-handle" /> <EllipsisVertical className="size-4 cursor-move item-handle" />
<div class="flex flex-row flex-1 gap-2 items-start"> <div class="flex flex-row flex-1 gap-2 items-start">
@ -73,30 +221,70 @@
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option> <option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select> </select>
<Textarea <!-- Use getter/setter approach instead of direct binding -->
className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none" <textarea
class="mr-2 text-xs w-full bg-transparent outline-hidden resize-none border-0 p-1"
placeholder={$i18n.t('Content')} placeholder={$i18n.t('Content')}
bind:value={banner.content} value={getContent(banner.id, langCode)}
maxSize={100} on:input={(e) => {
/> const value = e.target?.value || '';
setContent(banner.id, langCode, value);
}}
rows="2"
></textarea>
<div class="relative -left-2"> <div class="relative -left-2">
<Tooltip content={$i18n.t('Remember Dismissal')} className="flex h-fit items-center"> <Tooltip content={$i18n.t('Remember Dismissal')} class="flex h-fit items-center">
<Switch bind:state={banner.dismissible} /> <Switch bind:state={banner.dismissible} />
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<button <button class="p-1 text-gray-500 hover:text-yellow-600" type="button" on:click={() => openEditModal(bannerIdx)} title={$i18n.t('Edit')}>
class="pr-3" <PencilSolid />
type="button" </button>
on:click={() => {
banners.splice(bannerIdx, 1); <button class="pr-3" type="button" on:click={() => { banners.splice(bannerIdx, 1); banners = banners; }}>
banners = banners;
}}
>
<XMark className={'size-4'} /> <XMark className={'size-4'} />
</button> </button>
</div> </div>
{/each} {/each}
</div> </div>
<!-- Modal -->
{#if showBannerModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1">
<h2 class="text-sm font-bold mb-2">{$i18n.t('Edit Translations')}</h2>
<button class="text-xs px-2 py-1" on:click={closeModal}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22z"/>
</svg>
</button>
</div>
{#each LANGS as lang}
<div class="mb-2">
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
<Textarea
class="w-full text-sm p-1 border border-gray-300 dark:border-gray-700 rounded"
bind:value={newBanner.content[lang]}
placeholder={`Enter ${lang.toUpperCase()} content`}
maxSize={200}
on:input={() => syncModalToInline(lang)}
/>
</div>
{/each}
<div class="flex justify-end space-x-2 mt-3">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
on:click={saveModal}
>
{$i18n.t('Save')}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,135 @@
<script lang="ts">
export let selected: string[] = []; // array of selected codes
let query = '';
let open = false;
let highlighted = 0;
let inputEl: HTMLInputElement | null = null;
const codes: string[] = [
"aa","ab","ae","af","ak","am","an","ar","as","av","ay","az",
"ba","be","bg","bh","bi","bm","bn","bo","br","bs",
"ca","ce","ch","co","cr","cs","cu","cv","cy",
"da","de","dv","dz",
"ee","el","en","eo","es","et","eu",
"fa","ff","fi","fj","fo","fr","fy",
"ga","gd","gl","gn","gu","gv",
"ha","he","hi","ho","hr","ht","hu","hy","hz",
"ia","id","ie","ig","ii","ik","io","is","it","iu",
"ja","jv",
"ka","kg","ki","kj","kk","kl","km","kn","ko","kr","ks","ku","kv","kw","ky",
"la","lb","lg","li","ln","lo","lt","lu","lv",
"mg","mh","mi","mk","ml","mn","mr","ms","mt","my",
"na","nb","nd","ne","ng","nl","nn","no","nr","nv","ny",
"oc","oj","om","or","os",
"pa","pi","pl","ps","pt",
"qu",
"rm","rn","ro","ru","rw",
"sa","sc","sd","se","sg","si","sk","sl","sm","sn","so","sq","sr","ss","st","su","sv","sw",
"ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty",
"ug","uk","ur","uz",
"ve","vi","vo",
"wa","wo",
"xh",
"yi","yo",
"za","zh","zu"
];
const allOptions = codes.map(code => ({ code, label: code }));
$: suggestions = query
? allOptions
.filter(o => !selected.includes(o.code))
.filter(o => o.code.includes(query.toLowerCase()))
.slice(0, 10)
: allOptions.filter(o => !selected.includes(o.code)).slice(0, 10);
function add(code: string) {
const trimmedCode = code.trim();
if (trimmedCode && !selected.includes(trimmedCode)) {
selected = [...selected, trimmedCode];
}
query = '';
highlighted = 0;
inputEl?.focus();
}
function remove(code: string) {
selected = selected.filter(c => c !== code);
inputEl?.focus();
}
function onKeydown(e: KeyboardEvent) {
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) open = true;
if (e.key === 'ArrowDown') {
e.preventDefault();
highlighted = Math.min(highlighted + 1, suggestions.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlighted = Math.max(highlighted - 1, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
if (suggestions.length > 0) add(suggestions[highlighted].code);
} else if (e.key === 'Escape') {
open = false;
}
}
function clickOutside(node: HTMLElement) {
const onDocClick = (e: MouseEvent) => {
if (!node.contains(e.target as Node)) open = false;
};
document.addEventListener('mousedown', onDocClick);
return { destroy() { document.removeEventListener('mousedown', onDocClick); } };
}
</script>
<div use:clickOutside class="w-full relative">
<div class="flex flex-wrap gap-1 border rounded px-2 py-1 min-h-[2.5rem] items-center">
{#each selected as code (code)}
<span class="px-2 py-0.5 bg-gray-200 dark:bg-gray-700 rounded flex items-center gap-1 text-xs">
{code}
<button
type="button"
aria-label={`Remove ${code}`}
class="text-xs font-bold"
on:click={() => remove(code)}
>
×
</button>
</span>
{/each}
<input
bind:this={inputEl}
class="flex-1 outline-none bg-transparent min-w-[5ch]"
placeholder="Type code..."
bind:value={query}
on:input={() => { open = true; highlighted = 0; }}
on:keydown={onKeydown}
aria-haspopup="listbox"
aria-expanded={open}
/>
</div>
{#if open && suggestions.length > 0}
<ul
class="absolute z-10 mt-1 w-full max-h-40 overflow-auto border rounded bg-white dark:bg-gray-900 shadow-md"
role="listbox"
>
{#each suggestions as s, i (s.code)}
<li
role="option"
aria-selected={i === highlighted}
class="px-3 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 {i === highlighted ? 'bg-gray-200 dark:bg-gray-700' : ''}"
tabindex="0"
on:click={() => add(s.code)}
on:keydown={(e) => { if(e.key==='Enter') add(s.code) }}
>
{s.code}
</li>
{/each}
</ul>
{/if}
</div>

View file

@ -7,6 +7,7 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import { WEBUI_NAME, config, mobile, models as _models, settings, user } from '$lib/stores'; import { WEBUI_NAME, config, mobile, models as _models, settings, user } from '$lib/stores';
import { getTranslatedLabel } from '$lib/i18n';
import { import {
createNewModel, createNewModel,
deleteAllModels, deleteAllModels,
@ -72,6 +73,8 @@
let searchValue = ''; let searchValue = '';
$: langCode = $i18n.language?.split('-')[0] || 'de';
const downloadModels = async (models) => { const downloadModels = async (models) => {
let blob = new Blob([JSON.stringify(models)], { let blob = new Blob([JSON.stringify(models)], {
type: 'application/json' type: 'application/json'
@ -206,7 +209,7 @@
...model, ...model,
base_model_id: model.id, base_model_id: model.id,
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');
}; };
@ -364,7 +367,7 @@
className=" w-fit" className=" w-fit"
placement="top-start" placement="top-start"
> >
<div class=" font-semibold line-clamp-1">{model.name}</div> <div class=" font-semibold line-clamp-1">{getTranslatedLabel(model.name, langCode)}</div>
</Tooltip> </Tooltip>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500"> <div class=" text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
<span class=" line-clamp-1"> <span class=" line-clamp-1">

View file

@ -15,7 +15,7 @@
let sortedPrompts = []; let sortedPrompts = [];
const fuseOptions = { const fuseOptions = {
keys: ['content', 'title'], keys: ['translatedContent', 'title'],
threshold: 0.5 threshold: 0.5
}; };
@ -30,11 +30,11 @@
$: getFilteredPrompts(inputValue); $: getFilteredPrompts(inputValue);
// Helper function to check if arrays are the same // Helper function to check if arrays are the same
// (based on unique IDs oder content) // (based on unique IDs or content)
function arraysEqual(a, b) { function arraysEqual(a, b) {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) { for (let i = 0; i < a.length; i++) {
if ((a[i].id ?? a[i].content) !== (b[i].id ?? b[i].content)) { if ((a[i].id ?? a[i].translatedContent ?? a[i].content) !== (b[i].id ?? b[i].translatedContent ?? b[i].content)) {
return false; return false;
} }
} }
@ -58,10 +58,37 @@
} }
}; };
// Helper function to parse prompt content
function parsePromptContent(content) {
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch {
// If it's not valid JSON, treat it as a plain string
return { de: content };
}
}
return content || {};
}
// Helper function to get translated content
function getTranslatedContent(prompt, lang) {
const parsed = parsePromptContent(prompt.content);
return parsed[lang] || parsed['de'] || parsed[Object.keys(parsed)[0]] || '';
}
$: if (suggestionPrompts) { $: if (suggestionPrompts) {
sortedPrompts = [...(suggestionPrompts ?? [])].sort(() => Math.random() - 0.5); // Parse all prompts and add translated content for filtering
sortedPrompts = [...(suggestionPrompts ?? [])]
.map(prompt => ({
...prompt,
translatedContent: getTranslatedContent(prompt, langCode)
}))
.sort(() => Math.random() - 0.5);
getFilteredPrompts(inputValue); getFilteredPrompts(inputValue);
} }
$: langCode = $i18n.language?.split('-')[0] || 'de';
</script> </script>
<div class="mb-1 flex gap-1 text-xs font-medium items-center text-gray-600 dark:text-gray-400"> <div class="mb-1 flex gap-1 text-xs font-medium items-center text-gray-600 dark:text-gray-400">
@ -92,7 +119,8 @@
px-3 py-2 rounded-xl bg-transparent hover:bg-black/5 px-3 py-2 rounded-xl bg-transparent hover:bg-black/5
dark:hover:bg-white/5 transition group" dark:hover:bg-white/5 transition group"
style="animation-delay: {idx * 60}ms" style="animation-delay: {idx * 60}ms"
on:click={() => onSelect({ type: 'prompt', data: prompt.content })} on:click={() =>
onSelect({ type: 'prompt', data: prompt.translatedContent })}
> >
<div class="flex flex-col text-left"> <div class="flex flex-col text-left">
{#if prompt.title && prompt.title[0] !== ''} {#if prompt.title && prompt.title[0] !== ''}
@ -108,7 +136,7 @@
<div <div
class="font-medium dark:text-gray-300 dark:group-hover:text-gray-200 transition line-clamp-1" class="font-medium dark:text-gray-300 dark:group-hover:text-gray-200 transition line-clamp-1"
> >
{prompt.content} {prompt.translatedContent}
</div> </div>
<div class="text-xs text-gray-600 dark:text-gray-400 font-normal line-clamp-1"> <div class="text-xs text-gray-600 dark:text-gray-400 font-normal line-clamp-1">
{$i18n.t('Prompt')} {$i18n.t('Prompt')}

View file

@ -5,6 +5,7 @@
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { marked } from 'marked'; import { marked } from 'marked';
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
import { getLangCode, getTranslatedLabel } from '$lib/i18n';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -41,6 +42,7 @@
console.log('Banner mounted:', banner); console.log('Banner mounted:', banner);
}); });
$: langCode = getLangCode($i18n.language, 'en');
</script> </script>
{#if !dismissed} {#if !dismissed}
@ -100,7 +102,7 @@
{/if} {/if}
</div> </div>
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-60 overflow-y-auto"> <div class="flex-1 text-xs text-gray-700 dark:text-white max-h-60 overflow-y-auto">
{@html marked.parse(DOMPurify.sanitize((banner?.content ?? '').replace(/\n/g, '<br>')))} {@html marked.parse(DOMPurify.sanitize((getTranslatedLabel(banner?.content, langCode) ?? '').replace(/\n/g, '<br>')))}
</div> </div>
</div> </div>

View file

@ -26,6 +26,7 @@
import { getGroups } from '$lib/apis/groups'; import { getGroups } from '$lib/apis/groups';
import { capitalizeFirstLetter, copyToClipboard } from '$lib/utils'; import { capitalizeFirstLetter, copyToClipboard } from '$lib/utils';
import { getTranslatedLabel } from '$lib/i18n';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import ModelMenu from './Models/ModelMenu.svelte'; import ModelMenu from './Models/ModelMenu.svelte';
@ -132,7 +133,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, $i18n.language?.split('-')[0] || 'de')} (Clone)`
}); });
goto('/workspace/models/create'); goto('/workspace/models/create');
}; };
@ -245,6 +246,8 @@
window.removeEventListener('blur-sm', onBlur); window.removeEventListener('blur-sm', onBlur);
}; };
}); });
$: langCode = $i18n.language?.split('-')[0] || 'de';
</script> </script>
<svelte:head> <svelte:head>
@ -469,12 +472,12 @@
<div class="flex h-full w-full flex-1 flex-col justify-start self-center group"> <div class="flex h-full w-full flex-1 flex-col justify-start self-center group">
<div class="flex-1 w-full"> <div class="flex-1 w-full">
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<Tooltip content={model.name} className=" w-fit" placement="top-start"> <Tooltip content={getTranslatedLabel(model.name, langCode)} className=" w-fit" placement="top-start">
<a <a
class=" font-medium line-clamp-1 hover:underline capitalize" class=" font-medium line-clamp-1 hover:underline capitalize"
href={`/?models=${encodeURIComponent(model.id)}`} href={`/?models=${encodeURIComponent(model.id)}`}
> >
{model.name} {getTranslatedLabel(model.name, langCode)}
</a> </a>
</Tooltip> </Tooltip>

View file

@ -2,7 +2,7 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { onMount, getContext, tick } from 'svelte'; import { onMount, getContext, tick } from 'svelte';
import { models, tools, functions, knowledge as knowledgeCollections, user } from '$lib/stores'; import { models, tools, functions, knowledge as knowledgeCollections, user, config } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
import { getTools } from '$lib/apis/tools'; import { getTools } from '$lib/apis/tools';
@ -20,6 +20,7 @@
import AccessControl from '../common/AccessControl.svelte'; import AccessControl from '../common/AccessControl.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte'; import XMark from '$lib/components/icons/XMark.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import DefaultFiltersSelector from './DefaultFiltersSelector.svelte'; import DefaultFiltersSelector from './DefaultFiltersSelector.svelte';
import DefaultFeatures from './DefaultFeatures.svelte'; import DefaultFeatures from './DefaultFeatures.svelte';
import PromptSuggestions from './PromptSuggestions.svelte'; import PromptSuggestions from './PromptSuggestions.svelte';
@ -55,11 +56,27 @@
let id = ''; let id = '';
let name = ''; let name = '';
// Translation support
let showTitleModal = false;
let titleTranslations = {};
let showPromptModal = false;
let currentPromptIdx = -1;
let currentPromptTranslations = {};
$: langCode = $i18n.language?.split('-')[0] || 'de';
$: LANGS = Array.isArray($config.features.translation_languages)
? [...new Set([...$config.features.translation_languages, langCode])]
: [langCode, 'de'];
// Keep name synchronized with the current language translation
$: name = titleTranslations[langCode] || '';
let enableDescription = true; let enableDescription = true;
$: if (!edit) { $: if (!edit) {
if (name) { const currentName = titleTranslations[langCode] || '';
id = name if (currentName) {
id = currentName
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-]/g, '') .replace(/[^a-zA-Z0-9-]/g, '')
.toLowerCase(); .toLowerCase();
@ -120,11 +137,67 @@
} }
}; };
// Translation helper functions
function createEmptyTranslations() {
const translations = {};
LANGS.forEach(lang => {
translations[lang] = '';
});
return translations;
}
function parseContentToObj(content) {
let parsed = {};
try {
parsed = typeof content === 'string' ? JSON.parse(content) : { ...content };
} catch {
parsed = { [LANGS[0] || 'de']: content || '' };
}
// ensure all languages from config exist
for (const lang of LANGS) {
if (parsed[lang] == null) parsed[lang] = '';
}
return parsed;
}
function initializeTitleTranslations(existingName) {
if (existingName) {
titleTranslations = parseContentToObj(existingName);
} else {
titleTranslations = createEmptyTranslations();
}
}
function getPromptTranslation(promptContent) {
const parsed = parseContentToObj(promptContent);
return parsed[langCode] || parsed[LANGS[0]] || '';
}
function openPromptTranslationModal(idx) {
currentPromptIdx = idx;
const promptContent = info.meta.suggestion_prompts[idx].content;
currentPromptTranslations = parseContentToObj(promptContent);
showPromptModal = true;
}
function savePromptTranslation() {
if (currentPromptIdx >= 0 && info.meta.suggestion_prompts[currentPromptIdx]) {
info.meta.suggestion_prompts[currentPromptIdx].content = JSON.stringify(currentPromptTranslations);
info.meta.suggestion_prompts = info.meta.suggestion_prompts;
}
showPromptModal = false;
currentPromptIdx = -1;
}
// Update name to store the full translation object
$: info.name = JSON.stringify(titleTranslations);
const submitHandler = async () => { const submitHandler = async () => {
loading = true; loading = true;
info.id = id; info.id = id;
info.name = name; // info.name is set by reactive statement from titleTranslations
if (id === '') { if (id === '') {
toast.error($i18n.t('Model ID is required.')); toast.error($i18n.t('Model ID is required.'));
@ -133,7 +206,7 @@
return; return;
} }
if (name === '') { if (!titleTranslations[langCode] || titleTranslations[langCode].trim() === '') {
toast.error($i18n.t('Model Name is required.')); toast.error($i18n.t('Model Name is required.'));
loading = false; loading = false;
@ -232,7 +305,8 @@
} }
if (model) { if (model) {
name = model.name; // Initialize translations from model name
initializeTitleTranslations(model.name);
await tick(); await tick();
id = model.id; id = model.id;
@ -313,6 +387,9 @@
}; };
console.log(model); console.log(model);
} else {
// Initialize empty translations for new model
titleTranslations = createEmptyTranslations();
} }
loaded = true; loaded = true;
@ -320,6 +397,78 @@
</script> </script>
{#if loaded} {#if loaded}
<!-- Translation Modal -->
{#if showTitleModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1">
<h2 class="text-sm font-bold mb-2">{$i18n.t('Edit Title Translations')}</h2>
<button class="text-xs px-2 py-1" on:click={() => (showTitleModal = false)}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
{#each LANGS as lang}
<div class="mb-2">
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
<input
class="w-full text-sm p-1 border border-gray-300 dark:border-gray-700 rounded"
bind:value={titleTranslations[lang]}
placeholder={`Enter ${lang.toUpperCase()} title`}
/>
</div>
{/each}
<div class="flex justify-end space-x-2 mt-3">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
on:click={() => (showTitleModal = false)}
>
Save
</button>
</div>
</div>
</div>
{/if}
<!-- Prompt Translation Modal -->
{#if showPromptModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1">
<h2 class="text-sm font-bold mb-2">{$i18n.t('Edit Prompt Translations')}</h2>
<button class="text-xs px-2 py-1" on:click={() => (showPromptModal = false)}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
{#each LANGS as lang}
<div class="mb-2">
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
<input
class="w-full text-sm p-1 border border-gray-300 dark:border-gray-700 rounded"
bind:value={currentPromptTranslations[lang]}
placeholder={`Enter ${lang.toUpperCase()} prompt`}
/>
</div>
{/each}
<div class="flex justify-end space-x-2 mt-3">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
on:click={savePromptTranslation}
>
Save
</button>
</div>
</div>
</div>
{/if}
<AccessControlModal <AccessControlModal
bind:show={showAccessControlModal} bind:show={showAccessControlModal}
bind:accessControl bind:accessControl
@ -445,13 +594,13 @@
<img <img
src={info.meta.profile_image_url} src={info.meta.profile_image_url}
alt="model profile" alt="model profile"
class="rounded-xl sm:size-60 size-max object-cover shrink-0" class="rounded-xl size-72 md:size-60 object-cover shrink-0"
/> />
{:else} {:else}
<img <img
src="{WEBUI_BASE_URL}/static/favicon.png" src="{WEBUI_BASE_URL}/static/favicon.png"
alt="model profile" alt="model profile"
class=" rounded-xl sm:size-60 size-max object-cover shrink-0" class=" rounded-xl size-72 md:size-60 object-cover shrink-0"
/> />
{/if} {/if}
@ -496,22 +645,30 @@
</div> </div>
<div class="w-full"> <div class="w-full">
<div class="flex flex-col"> <div class="mt-2 my-2 flex flex-col">
<div class="flex justify-between items-start my-2"> <div class="flex-1">
<div class=" flex flex-col w-full"> <div class="flex items-center">
<div class="flex-1 w-full">
<input <input
class="text-4xl font-medium w-full bg-transparent outline-hidden" class="text-3xl font-medium w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Model Name')} placeholder={$i18n.t('Model Name')}
bind:value={name} bind:value={titleTranslations[langCode]}
required required
/> />
{#if titleTranslations[langCode]}
<button class="ml-2" type="button" on:click={() => (showTitleModal = true)}>
<div class="self-center mr-2">
<PencilSolid />
</div>
</button>
{/if}
</div>
</div> </div>
<div class="flex-1 w-full"> <div class="flex-1">
<div> <div>
<input <input
class="text-xs w-full bg-transparent outline-hidden" class="text-xs w-full bg-transparent text-gray-500 outline-hidden"
placeholder={$i18n.t('Model ID')} placeholder={$i18n.t('Model ID')}
bind:value={id} bind:value={id}
disabled={edit} disabled={edit}
@ -521,32 +678,13 @@
</div> </div>
</div> </div>
<div class="shrink-0">
<button
class="bg-gray-50 shrink-0 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5 shrink-0" />
<div class="text-sm font-medium shrink-0">
{$i18n.t('Access')}
</div>
</button>
</div>
</div>
{#if preset} {#if preset}
<div class="mb-1"> <div class="my-1">
<div class=" text-xs font-medium mb-1 text-gray-500"> <div class=" text-sm font-medium mb-1">{$i18n.t('Base Model (From)')}</div>
{$i18n.t('Base Model (From)')}
</div>
<div> <div>
<select <select
class="dark:bg-gray-900 text-sm w-full bg-transparent outline-hidden" class="text-sm w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Select a base model (e.g. llama3, gpt-4o)')} placeholder={$i18n.t('Select a base model (e.g. llama3, gpt-4o)')}
bind:value={info.base_model_id} bind:value={info.base_model_id}
on:change={(e) => { on:change={(e) => {
@ -565,11 +703,9 @@
</div> </div>
{/if} {/if}
<div class="mb-1"> <div class="my-1">
<div class="mb-1 flex w-full justify-between items-center"> <div class="mb-1 flex w-full justify-between items-center">
<div class=" self-center text-xs font-medium text-gray-500"> <div class=" self-center text-sm font-medium">{$i18n.t('Description')}</div>
{$i18n.t('Description')}
</div>
<button <button
class="p-1 text-xs flex rounded-sm transition" class="p-1 text-xs flex rounded-sm transition"
@ -599,7 +735,7 @@
{/if} {/if}
</div> </div>
<div class="w-full mb-1 max-w-full"> <div class=" mt-2 my-1">
<div class=""> <div class="">
<Tags <Tags
tags={info?.meta?.tags ?? []} tags={info?.meta?.tags ?? []}
@ -618,15 +754,23 @@
/> />
</div> </div>
</div> </div>
<div class="my-2">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl
bind:accessControl
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.models || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'}
/>
</div>
</div> </div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" /> <hr class=" border-gray-100 dark:border-gray-850 my-1.5" />
<div class="my-2"> <div class="my-2">
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium text-gray-500"> <div class=" self-center text-sm font-medium">{$i18n.t('Model Params')}</div>
{$i18n.t('Model Params')}
</div>
</div> </div>
<div class="mt-2"> <div class="mt-2">
@ -672,12 +816,12 @@
</div> </div>
</div> </div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" /> <hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="my-2"> <div class="my-2">
<div class="flex w-full justify-between items-center"> <div class="flex w-full justify-between items-center">
<div class="flex w-full justify-between items-center"> <div class="flex w-full justify-between items-center">
<div class=" self-center text-xs font-medium text-gray-500"> <div class=" self-center text-sm font-medium">
{$i18n.t('Prompts')} {$i18n.t('Prompts')}
</div> </div>
@ -686,7 +830,7 @@
type="button" type="button"
on:click={() => { on:click={() => {
if ((info?.meta?.suggestion_prompts ?? null) === null) { if ((info?.meta?.suggestion_prompts ?? null) === null) {
info.meta.suggestion_prompts = [{ content: '', title: ['', ''] }]; info.meta.suggestion_prompts = [{ content: JSON.stringify(createEmptyTranslations()), title: ['', ''] }];
} else { } else {
info.meta.suggestion_prompts = null; info.meta.suggestion_prompts = null;
} }
@ -699,29 +843,94 @@
{/if} {/if}
</button> </button>
</div> </div>
</div>
{#if info?.meta?.suggestion_prompts} {#if (info?.meta?.suggestion_prompts ?? null) !== null}
<PromptSuggestions bind:promptSuggestions={info.meta.suggestion_prompts} /> <button
class="p-1 px-2 text-xs flex rounded-sm transition"
type="button"
aria-label={$i18n.t('Add prompt suggestion')}
on:click={() => {
if (
info.meta.suggestion_prompts.length === 0 ||
getPromptTranslation(info.meta.suggestion_prompts.at(-1).content) !== ''
) {
info.meta.suggestion_prompts = [
...info.meta.suggestion_prompts,
{ content: JSON.stringify(createEmptyTranslations()), title: ['', ''] }
];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
{/if} {/if}
</div> </div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" /> {#if info?.meta?.suggestion_prompts}
<div class="flex flex-col space-y-1 mt-1 mb-3">
{#if info.meta.suggestion_prompts.length > 0}
{#each info.meta.suggestion_prompts as prompt, promptIdx}
<div class=" flex rounded-lg items-center">
<input
class=" text-sm w-full bg-transparent outline-hidden border-r border-gray-100 dark:border-gray-850"
placeholder={$i18n.t('Write a prompt suggestion (e.g. Who are you?)')}
value={getPromptTranslation(prompt.content)}
on:input={(e) => {
const translations = parseContentToObj(prompt.content);
translations[langCode] = e.target.value;
prompt.content = JSON.stringify(translations);
info.meta.suggestion_prompts = info.meta.suggestion_prompts;
}}
/>
{#if getPromptTranslation(prompt.content)}
<button
class="px-2"
type="button"
on:click={() => openPromptTranslationModal(promptIdx)}
>
<PencilSolid />
</button>
{/if}
<button
class="px-2"
type="button"
on:click={() => {
info.meta.suggestion_prompts.splice(promptIdx, 1);
info.meta.suggestion_prompts = info.meta.suggestion_prompts;
}}
>
<XMark className={'size-4'} />
</button>
</div>
{/each}
{:else}
<div class="text-xs text-center">{$i18n.t('No suggestion prompts')}</div>
{/if}
</div>
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-1.5" />
<div class="my-2"> <div class="my-2">
<Knowledge bind:selectedItems={knowledge} /> <Knowledge bind:selectedItems={knowledge} />
</div> </div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="my-2"> <div class="my-2">
<ToolsSelector bind:selectedToolIds={toolIds} tools={$tools} /> <ToolsSelector bind:selectedToolIds={toolIds} tools={$tools} />
</div> </div>
{#if $functions.filter((func) => func.type === 'filter').length > 0 || $functions.filter((func) => func.type === 'action').length > 0}
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
{#if $functions.filter((func) => func.type === 'filter').length > 0}
<div class="my-2"> <div class="my-2">
<FiltersSelector <FiltersSelector
bind:selectedFilterIds={filterIds} bind:selectedFilterIds={filterIds}
@ -746,19 +955,13 @@
</div> </div>
{/if} {/if}
{/if} {/if}
{/if}
{#if $functions.filter((func) => func.type === 'action').length > 0}
<div class="my-2"> <div class="my-2">
<ActionsSelector <ActionsSelector
bind:selectedActionIds={actionIds} bind:selectedActionIds={actionIds}
actions={$functions.filter((func) => func.type === 'action')} actions={$functions.filter((func) => func.type === 'action')}
/> />
</div> </div>
{/if}
{/if}
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="my-2"> <div class="my-2">
<Capabilities bind:capabilities /> <Capabilities bind:capabilities />
@ -779,33 +982,7 @@
{/if} {/if}
{/if} {/if}
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" /> <div class="my-2 text-gray-300 dark:text-gray-700">
<div class="my-2 flex justify-end">
<button
class=" text-sm px-3 py-2 transition rounded-lg {loading
? ' cursor-not-allowed bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center"
type="submit"
disabled={loading}
>
<div class=" self-center font-medium">
{#if edit}
{$i18n.t('Save & Update')}
{:else}
{$i18n.t('Save & Create')}
{/if}
</div>
{#if loading}
<div class="ml-1.5 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
<div class="my-2 text-gray-300 dark:text-gray-700 pb-20">
<div class="flex w-full justify-between mb-2"> <div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-medium">{$i18n.t('JSON Preview')}</div> <div class=" self-center text-sm font-medium">{$i18n.t('JSON Preview')}</div>
@ -836,6 +1013,30 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="my-2 flex justify-end pb-20">
<button
class=" text-sm px-3 py-2 transition rounded-lg {loading
? ' cursor-not-allowed bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center"
type="submit"
disabled={loading}
>
<div class=" self-center font-medium">
{#if edit}
{$i18n.t('Save & Update')}
{:else}
{$i18n.t('Save & Create')}
{/if}
</div>
{#if loading}
<div class="ml-1.5 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</div> </div>
</form> </form>
{/if} {/if}

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,145 @@ 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();
// Utils for translations
export interface Translations {
[key: string]: string;
}
/**
* Extracts the language code from a locale string (e.g., 'en-US' -> 'en')
* @param language - Full language code (e.g., 'en-US', 'es-ES')
* @param fallback - Default language code if parsing fails
* @returns Language code (e.g., 'en', 'es', 'de')
*/
export function getLangCode(language?: string, fallback: string = 'de'): string {
return language?.split('-')[0] || fallback;
}
// Language family definitions for fallback translations
const LANGUAGE_FAMILIES: Record<string, string[]> = {
en: ['en', 'en-US', 'en-GB'],
de: ['de', 'de-DE', 'de-CH'],
fr: ['fr', 'fr-CH', 'fr-CA', 'fr-FR'],
it: ['it', 'it-IT', 'it-CH']
};
// Default fallback order when user's language is not found
const DEFAULT_FALLBACK = [
'de', 'de-DE', 'de-CH',
'en', 'en-US', 'en-GB',
'fr', 'fr-CH', 'fr-CA', 'fr-FR',
'it', 'it-IT', 'it-CH'
];
/**
* Gets translated label from translation object or JSON string
* @param label - Translation object or JSON string containing translations
* @param langCode - Target language code (e.g., 'en', 'es', 'de')
* @returns Translated string or empty string if not found
*/
export function getTranslatedLabel(
label: string | Translations | null | undefined,
langCode: string
): string {
if (!label) return '';
try {
// If it's a plain string (not JSON), return it as-is
if (typeof label === 'string' && !label.trim().startsWith('{')) {
return label;
}
// If it's already an object, use it directly
const rawTranslations: Translations = typeof label === 'object' ? label : JSON.parse(label);
// Normalize translations object by trimming all keys and values
const translations: Translations = {};
for (const [key, value] of Object.entries(rawTranslations)) {
const trimmedKey = key.trim();
const trimmedValue = typeof value === 'string' ? value.trim() : value;
if (trimmedKey) {
translations[trimmedKey] = trimmedValue;
}
}
// Extract base language code (e.g., 'it' from 'it-CH', 'fr' from 'fr-CA')
// Also trim the langCode to handle any spaces
const cleanLangCode = langCode.trim();
const baseLangCode = cleanLangCode.split('-')[0];
// Build priority list for lookups
const priorityList: string[] = [];
// 1. Add exact match first
priorityList.push(cleanLangCode);
// 2. Add user's language family (if it exists)
const userFamily = LANGUAGE_FAMILIES[baseLangCode];
if (userFamily) {
// Add all variants from user's family, excluding the exact match already added
userFamily.forEach(variant => {
if (variant !== cleanLangCode && !priorityList.includes(variant)) {
priorityList.push(variant);
}
});
}
// 3. Add default fallback order, excluding already added languages
DEFAULT_FALLBACK.forEach(fallbackLang => {
if (!priorityList.includes(fallbackLang)) {
priorityList.push(fallbackLang);
}
});
// Try each language in priority order and return first non-empty translation
for (const lang of priorityList) {
const translation = translations[lang];
if (translation && translation.trim() !== '') {
return translation;
}
}
// If nothing found, return empty string
return '';
} catch (error) {
// Log parsing errors for debugging in development
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to parse translation label:', label, error);
}
// If parsing fails, return the original value if it's a string
return typeof label === 'string' ? label : '';
}
}
/**
* Convenience function that combines getLangCode and getTranslatedLabel
* @param label - Translation object or JSON string
* @param language - Full language code (e.g., 'en-US')
* @param fallback - Fallback language code
* @returns Translated string
*/
export function translate(
label: string | Translations | null | undefined,
language?: string,
fallback: string = 'de'
): string {
const langCode = getLangCode(language, fallback);
return getTranslatedLabel(label, langCode);
}

View file

@ -274,6 +274,7 @@ type Config = {
enable_autocomplete_generation: boolean; enable_autocomplete_generation: boolean;
enable_direct_connections: boolean; enable_direct_connections: boolean;
enable_version_update_check: boolean; enable_version_update_check: boolean;
translation_languages: string[];
}; };
oauth: { oauth: {
providers: { providers: {

View file

@ -16,6 +16,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,
@ -156,6 +157,7 @@
setBanners(), setBanners(),
setTools(), setTools(),
setUserSettings(async () => { setUserSettings(async () => {
customHeadersFetch();
await Promise.all([setModels(), setToolServers()]); await Promise.all([setModels(), setToolServers()]);
}) })
]); ]);

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';
import { Toaster, toast } from 'svelte-sonner'; import { Toaster, toast } from 'svelte-sonner';
let loadingProgress = spring(0, { let loadingProgress = spring(0, {
@ -730,6 +731,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();