This commit is contained in:
alvarellos 2025-12-09 22:23:33 +01:00 committed by GitHub
commit 5416ec4053
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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",
)
TRANSLATION_LANGUAGES = PersistentConfig(
"TRANSLATION_LANGUAGES",
"translation_languages",
os.getenv("TRANSLATION_LANGUAGES", "en").split(","),
)
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig(
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH",
"task.autocomplete.input_max_length",

View file

@ -432,6 +432,7 @@ from open_webui.config import (
QUERY_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
TRANSLATION_LANGUAGES,
AppConfig,
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_TITLE_GENERATION = ENABLE_TITLE_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
@ -1864,6 +1866,7 @@ async def get_app_config(request: Request):
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
"translation_languages": app.state.config.TRANSLATION_LANGUAGES,
**(
{
"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 pydantic import BaseModel
from typing import Optional
from typing import Optional, List
import logging
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,
"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,
"TRANSLATION_LANGUAGES": request.app.state.config.TRANSLATION_LANGUAGES,
"VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
}
@ -89,6 +90,7 @@ class TaskConfigForm(BaseModel):
ENABLE_RETRIEVAL_QUERY_GENERATION: bool
QUERY_GENERATION_PROMPT_TEMPLATE: str
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: str
TRANSLATION_LANGUAGES: Optional[List[str]] = []
VOICE_MODE_PROMPT_TEMPLATE: Optional[str]
@ -138,6 +140,10 @@ async def update_task_config(
request.app.state.config.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 = (
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,
"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,
"TRANSLATION_LANGUAGES": request.app.state.config.TRANSLATION_LANGUAGES,
"VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
}

View file

@ -2,7 +2,7 @@ import time
import logging
import asyncio
import sys
import json
from aiocache import cached
from fastapi import Request
@ -37,6 +37,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 [
@ -160,6 +187,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
0
] # 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:
model["name"] = custom_model.name
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:
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["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 { 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 (
token: string = '',
@ -9,6 +35,7 @@ export const getModelItems = async (
direction,
page
) => {
const lang = get(config)?.default_locale || 'en';
let error = null;
const searchParams = new URLSearchParams();
@ -61,12 +88,13 @@ export const getModelItems = async (
export const getModelTags = async (token: string = '') => {
let error = null;
const lang = get(config)?.default_locale || 'en';
const res = await fetch(`${WEBUI_API_BASE_URL}/models/tags`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Language': lang,
authorization: `Bearer ${token}`
}
})

View file

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

View file

@ -1,102 +1,290 @@
<script lang="ts">
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Sortable from 'sortablejs';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import Sortable from 'sortablejs';
import { getContext } from 'svelte';
import { config } from '$lib/stores';
export let banners = [];
const i18n = getContext('i18n');
let sortable = null;
let bannerListElement = null;
export let banners: any[] = [];
const positionChangeHandler = () => {
const bannerIdOrder = Array.from(bannerListElement.children).map((child) =>
child.id.replace('banner-item-', '')
);
let sortable: any = null;
let bannerListElement: HTMLDivElement | null = null;
// Sort the banners array based on the new order
banners = bannerIdOrder.map((id) => {
const index = banners.findIndex((banner) => banner.id === id);
return banners[index];
});
};
// reactive UI language code
$: langCode = $i18n?.language?.split('-')[0] || 'de';
const classNames: Record<string, string> = {
info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
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'
};
// dynamic languages from config
$: LANGS = Array.isArray($config.features.translation_languages)
? [...new Set([...$config.features.translation_languages, langCode])]
: [langCode, 'de'];
$: if (banners) {
init();
}
// contentObjs stores parsed content objects keyed by banner.id
let contentObjs: Record<string, Record<string, string>> = {};
const init = () => {
if (sortable) {
sortable.destroy();
}
// Create a helper function to get/set content safely
function getContent(bannerId: string, lang: string): string {
ensureContentStructure(bannerId);
return contentObjs[bannerId][lang] || '';
}
if (bannerListElement) {
sortable = new Sortable(bannerListElement, {
animation: 150,
handle: '.item-handle',
onUpdate: async (event) => {
positionChangeHandler();
}
});
}
};
function setContent(bannerId: string, lang: string, value: string) {
ensureContentStructure(bannerId);
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) {
sortable = Sortable.create(bannerListElement, {
animation: 150,
handle: '.item-handle',
onUpdate: () => {
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>
<div class=" flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
{#each banners as banner, bannerIdx (banner.id)}
<div class=" flex justify-between items-start -ml-1" id="banner-item-{banner.id}">
<EllipsisVertical className="size-4 cursor-move item-handle" />
<!-- Draggable banners -->
<div class="flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
{#each banners as banner, bannerIdx (banner.id)}
<div class="flex justify-between items-start -ml-1" id={"banner-item-" + banner.id}>
<EllipsisVertical className="size-4 cursor-move item-handle" />
<div class="flex flex-row flex-1 gap-2 items-start">
<select
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden pl-1 pr-5"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900">{$i18n.t('Type')}</option>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<div class="flex flex-row flex-1 gap-2 items-start">
<select
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden pl-1 pr-5"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900">{$i18n.t('Type')}</option>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<Textarea
className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
maxSize={100}
/>
<!-- Use getter/setter approach instead of direct binding -->
<textarea
class="mr-2 text-xs w-full bg-transparent outline-hidden resize-none border-0 p-1"
placeholder={$i18n.t('Content')}
value={getContent(banner.id, langCode)}
on:input={(e) => {
const value = e.target?.value || '';
setContent(banner.id, langCode, value);
}}
rows="2"
></textarea>
<div class="relative -left-2">
<Tooltip content={$i18n.t('Remember Dismissal')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
</div>
<div class="relative -left-2">
<Tooltip content={$i18n.t('Remember Dismissal')} class="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
</div>
<button
class="pr-3"
type="button"
on:click={() => {
banners.splice(bannerIdx, 1);
banners = banners;
}}
>
<XMark className={'size-4'} />
</button>
</div>
{/each}
<button class="p-1 text-gray-500 hover:text-yellow-600" type="button" on:click={() => openEditModal(bannerIdx)} title={$i18n.t('Edit')}>
<PencilSolid />
</button>
<button class="pr-3" type="button" on:click={() => { banners.splice(bannerIdx, 1); banners = banners; }}>
<XMark className={'size-4'} />
</button>
</div>
{/each}
</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');
import { WEBUI_NAME, config, mobile, models as _models, settings, user } from '$lib/stores';
import { getTranslatedLabel } from '$lib/i18n';
import {
createNewModel,
deleteAllModels,
@ -72,6 +73,8 @@
let searchValue = '';
$: langCode = $i18n.language?.split('-')[0] || 'de';
const downloadModels = async (models) => {
let blob = new Blob([JSON.stringify(models)], {
type: 'application/json'
@ -206,7 +209,7 @@
...model,
base_model_id: model.id,
id: `${model.id}-clone`,
name: `${model.name} (Clone)`
name: `${getTranslatedLabel(model.name, langCode)} (Clone)`
});
goto('/workspace/models/create');
};
@ -364,7 +367,7 @@
className=" w-fit"
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>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
<span class=" line-clamp-1">

View file

@ -15,7 +15,7 @@
let sortedPrompts = [];
const fuseOptions = {
keys: ['content', 'title'],
keys: ['translatedContent', 'title'],
threshold: 0.5
};
@ -30,11 +30,11 @@
$: getFilteredPrompts(inputValue);
// 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) {
if (a.length !== b.length) return false;
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;
}
}
@ -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) {
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);
}
$: langCode = $i18n.language?.split('-')[0] || 'de';
</script>
<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
dark:hover:bg-white/5 transition group"
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">
{#if prompt.title && prompt.title[0] !== ''}
@ -108,7 +136,7 @@
<div
class="font-medium dark:text-gray-300 dark:group-hover:text-gray-200 transition line-clamp-1"
>
{prompt.content}
{prompt.translatedContent}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 font-normal line-clamp-1">
{$i18n.t('Prompt')}

View file

@ -5,6 +5,7 @@
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { WEBUI_BASE_URL } from '$lib/constants';
import { getLangCode, getTranslatedLabel } from '$lib/i18n';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
@ -41,6 +42,7 @@
console.log('Banner mounted:', banner);
});
$: langCode = getLangCode($i18n.language, 'en');
</script>
{#if !dismissed}
@ -100,7 +102,7 @@
{/if}
</div>
<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>

View file

@ -26,6 +26,7 @@
import { getGroups } from '$lib/apis/groups';
import { capitalizeFirstLetter, copyToClipboard } from '$lib/utils';
import { getTranslatedLabel } from '$lib/i18n';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import ModelMenu from './Models/ModelMenu.svelte';
@ -132,7 +133,7 @@
sessionStorage.model = JSON.stringify({
...model,
id: `${model.id}-clone`,
name: `${model.name} (Clone)`
name: `${getTranslatedLabel(model.name, $i18n.language?.split('-')[0] || 'de')} (Clone)`
});
goto('/workspace/models/create');
};
@ -245,6 +246,8 @@
window.removeEventListener('blur-sm', onBlur);
};
});
$: langCode = $i18n.language?.split('-')[0] || 'de';
</script>
<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-1 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
class=" font-medium line-clamp-1 hover:underline capitalize"
href={`/?models=${encodeURIComponent(model.id)}`}
>
{model.name}
{getTranslatedLabel(model.name, langCode)}
</a>
</Tooltip>

View file

@ -2,7 +2,7 @@
import { toast } from 'svelte-sonner';
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 { getTools } from '$lib/apis/tools';
@ -20,6 +20,7 @@
import AccessControl from '../common/AccessControl.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import DefaultFiltersSelector from './DefaultFiltersSelector.svelte';
import DefaultFeatures from './DefaultFeatures.svelte';
import PromptSuggestions from './PromptSuggestions.svelte';
@ -55,11 +56,27 @@
let id = '';
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;
$: if (!edit) {
if (name) {
id = name
const currentName = titleTranslations[langCode] || '';
if (currentName) {
id = currentName
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-]/g, '')
.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 () => {
loading = true;
info.id = id;
info.name = name;
// info.name is set by reactive statement from titleTranslations
if (id === '') {
toast.error($i18n.t('Model ID is required.'));
@ -133,7 +206,7 @@
return;
}
if (name === '') {
if (!titleTranslations[langCode] || titleTranslations[langCode].trim() === '') {
toast.error($i18n.t('Model Name is required.'));
loading = false;
@ -232,7 +305,8 @@
}
if (model) {
name = model.name;
// Initialize translations from model name
initializeTitleTranslations(model.name);
await tick();
id = model.id;
@ -313,6 +387,9 @@
};
console.log(model);
} else {
// Initialize empty translations for new model
titleTranslations = createEmptyTranslations();
}
loaded = true;
@ -320,6 +397,78 @@
</script>
{#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
bind:show={showAccessControlModal}
bind:accessControl
@ -445,13 +594,13 @@
<img
src={info.meta.profile_image_url}
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}
<img
src="{WEBUI_BASE_URL}/static/favicon.png"
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}
@ -496,137 +645,132 @@
</div>
<div class="w-full">
<div class="flex flex-col">
<div class="flex justify-between items-start my-2">
<div class=" flex flex-col w-full">
<div class="flex-1 w-full">
<input
class="text-4xl font-medium w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Model Name')}
bind:value={name}
required
/>
</div>
<div class="flex-1 w-full">
<div>
<input
class="text-xs w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Model ID')}
bind:value={id}
disabled={edit}
required
/>
</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}
<div class="mb-1">
<div class=" text-xs font-medium mb-1 text-gray-500">
{$i18n.t('Base Model (From)')}
</div>
<div>
<select
class="dark:bg-gray-900 text-sm w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Select a base model (e.g. llama3, gpt-4o)')}
bind:value={info.base_model_id}
on:change={(e) => {
addUsage(e.target.value);
}}
required
>
<option value={null} class=" text-gray-900"
>{$i18n.t('Select a base model')}</option
>
{#each $models.filter((m) => (model ? m.id !== model.id : true) && !m?.preset && m?.owned_by !== 'arena' && !(m?.direct ?? false)) as model}
<option value={model.id} class=" text-gray-900">{model.name}</option>
{/each}
</select>
</div>
</div>
{/if}
<div class="mb-1">
<div class="mb-1 flex w-full justify-between items-center">
<div class=" self-center text-xs font-medium text-gray-500">
{$i18n.t('Description')}
</div>
<button
class="p-1 text-xs flex rounded-sm transition"
type="button"
aria-pressed={enableDescription ? 'true' : 'false'}
aria-label={enableDescription
? $i18n.t('Custom description enabled')
: $i18n.t('Default description enabled')}
on:click={() => {
enableDescription = !enableDescription;
}}
>
{#if !enableDescription}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
{#if enableDescription}
<Textarea
className=" text-sm w-full bg-transparent outline-hidden resize-none overflow-y-hidden "
placeholder={$i18n.t('Add a short description about what this model does')}
bind:value={info.meta.description}
<div class="mt-2 my-2 flex flex-col">
<div class="flex-1">
<div class="flex items-center">
<input
class="text-3xl font-medium w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Model Name')}
bind:value={titleTranslations[langCode]}
required
/>
{/if}
{#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 class="w-full mb-1 max-w-full">
<div class="">
<Tags
tags={info?.meta?.tags ?? []}
on:delete={(e) => {
const tagName = e.detail;
info.meta.tags = info.meta.tags.filter((tag) => tag.name !== tagName);
}}
on:add={(e) => {
const tagName = e.detail;
if (!(info?.meta?.tags ?? null)) {
info.meta.tags = [{ name: tagName }];
} else {
info.meta.tags = [...info.meta.tags, { name: tagName }];
}
}}
<div class="flex-1">
<div>
<input
class="text-xs w-full bg-transparent text-gray-500 outline-hidden"
placeholder={$i18n.t('Model ID')}
bind:value={id}
disabled={edit}
required
/>
</div>
</div>
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
{#if preset}
<div class="my-1">
<div class=" text-sm font-medium mb-1">{$i18n.t('Base Model (From)')}</div>
<div>
<select
class="text-sm w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Select a base model (e.g. llama3, gpt-4o)')}
bind:value={info.base_model_id}
on:change={(e) => {
addUsage(e.target.value);
}}
required
>
<option value={null} class=" text-gray-900"
>{$i18n.t('Select a base model')}</option
>
{#each $models.filter((m) => (model ? m.id !== model.id : true) && !m?.preset && m?.owned_by !== 'arena' && !(m?.direct ?? false)) as model}
<option value={model.id} class=" text-gray-900">{model.name}</option>
{/each}
</select>
</div>
</div>
{/if}
<div class="my-1">
<div class="mb-1 flex w-full justify-between items-center">
<div class=" self-center text-sm font-medium">{$i18n.t('Description')}</div>
<button
class="p-1 text-xs flex rounded-sm transition"
type="button"
aria-pressed={enableDescription ? 'true' : 'false'}
aria-label={enableDescription
? $i18n.t('Custom description enabled')
: $i18n.t('Default description enabled')}
on:click={() => {
enableDescription = !enableDescription;
}}
>
{#if !enableDescription}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
{#if enableDescription}
<Textarea
className=" text-sm w-full bg-transparent outline-hidden resize-none overflow-y-hidden "
placeholder={$i18n.t('Add a short description about what this model does')}
bind:value={info.meta.description}
/>
{/if}
</div>
<div class=" mt-2 my-1">
<div class="">
<Tags
tags={info?.meta?.tags ?? []}
on:delete={(e) => {
const tagName = e.detail;
info.meta.tags = info.meta.tags.filter((tag) => tag.name !== tagName);
}}
on:add={(e) => {
const tagName = e.detail;
if (!(info?.meta?.tags ?? null)) {
info.meta.tags = [{ name: tagName }];
} else {
info.meta.tags = [...info.meta.tags, { name: tagName }];
}
}}
/>
</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>
<hr class=" border-gray-100 dark:border-gray-850 my-1.5" />
<div class="my-2">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium text-gray-500">
{$i18n.t('Model Params')}
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Model Params')}</div>
</div>
<div class="mt-2">
@ -672,12 +816,12 @@
</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="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')}
</div>
@ -686,7 +830,7 @@
type="button"
on:click={() => {
if ((info?.meta?.suggestion_prompts ?? null) === null) {
info.meta.suggestion_prompts = [{ content: '', title: ['', ''] }];
info.meta.suggestion_prompts = [{ content: JSON.stringify(createEmptyTranslations()), title: ['', ''] }];
} else {
info.meta.suggestion_prompts = null;
}
@ -699,66 +843,125 @@
{/if}
</button>
</div>
{#if (info?.meta?.suggestion_prompts ?? null) !== null}
<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}
</div>
{#if info?.meta?.suggestion_prompts}
<PromptSuggestions bind:promptSuggestions={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/30 dark:border-gray-850/30 my-2" />
<hr class=" border-gray-100 dark:border-gray-850 my-1.5" />
<div class="my-2">
<Knowledge bind:selectedItems={knowledge} />
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="my-2">
<ToolsSelector bind:selectedToolIds={toolIds} tools={$tools} />
</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" />
<div class="my-2">
<FiltersSelector
bind:selectedFilterIds={filterIds}
filters={$functions.filter((func) => func.type === 'filter')}
/>
</div>
{#if $functions.filter((func) => func.type === 'filter').length > 0}
{#if filterIds.length > 0}
{@const toggleableFilters = $functions.filter(
(func) =>
func.type === 'filter' &&
(filterIds.includes(func.id) || func?.is_global) &&
func?.meta?.toggle
)}
{#if toggleableFilters.length > 0}
<div class="my-2">
<FiltersSelector
bind:selectedFilterIds={filterIds}
filters={$functions.filter((func) => func.type === 'filter')}
/>
</div>
{#if filterIds.length > 0}
{@const toggleableFilters = $functions.filter(
(func) =>
func.type === 'filter' &&
(filterIds.includes(func.id) || func?.is_global) &&
func?.meta?.toggle
)}
{#if toggleableFilters.length > 0}
<div class="my-2">
<DefaultFiltersSelector
bind:selectedFilterIds={defaultFilterIds}
filters={toggleableFilters}
/>
</div>
{/if}
{/if}
{/if}
{#if $functions.filter((func) => func.type === 'action').length > 0}
<div class="my-2">
<ActionsSelector
bind:selectedActionIds={actionIds}
actions={$functions.filter((func) => func.type === 'action')}
<DefaultFiltersSelector
bind:selectedFilterIds={defaultFilterIds}
filters={toggleableFilters}
/>
</div>
{/if}
{/if}
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="my-2">
<ActionsSelector
bind:selectedActionIds={actionIds}
actions={$functions.filter((func) => func.type === 'action')}
/>
</div>
<div class="my-2">
<Capabilities bind:capabilities />
@ -779,33 +982,7 @@
{/if}
{/if}
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<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="my-2 text-gray-300 dark:text-gray-700">
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-medium">{$i18n.t('JSON Preview')}</div>
@ -836,6 +1013,30 @@
</div>
{/if}
</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>
</form>
{/if}

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,145 @@ 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();
// 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_direct_connections: boolean;
enable_version_update_check: boolean;
translation_languages: string[];
};
oauth: {
providers: {

View file

@ -16,6 +16,7 @@
import { WEBUI_VERSION } from '$lib/constants';
import { compareVersion } from '$lib/utils';
import { customHeadersFetch } from '../../hooks.client';
import {
config,
@ -156,6 +157,7 @@
setBanners(),
setTools(),
setUserSettings(async () => {
customHeadersFetch();
await Promise.all([setModels(), setToolServers()]);
})
]);

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