mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 12:25:20 +00:00
Merge 8621445baf into ae47101dc6
This commit is contained in:
commit
f279af5915
18 changed files with 1160 additions and 310 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
||||
|
|
@ -139,6 +141,10 @@ async def update_task_config(
|
|||
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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
33
src/hooks.client.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { get } from 'svelte/store';
|
||||
import { temporaryChatEnabled } from '$lib/stores';
|
||||
import { getI18nStore } from './lib/i18n';
|
||||
|
||||
export function customHeadersFetch() {
|
||||
const originalFetch = window.fetch;
|
||||
const i18nStore = getI18nStore();
|
||||
|
||||
window.fetch = async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
// get current values
|
||||
const i18n = get(i18nStore);
|
||||
|
||||
// normalize headers to a plain object
|
||||
const existingHeaders =
|
||||
init.headers instanceof Headers
|
||||
? Object.fromEntries(init.headers.entries())
|
||||
: Array.isArray(init.headers)
|
||||
? Object.fromEntries(init.headers)
|
||||
: { ...(init.headers || {}) };
|
||||
|
||||
const newHeaders: Record<string, string> = { ...existingHeaders };
|
||||
|
||||
|
||||
if (i18n?.language) {
|
||||
newHeaders['X-Language'] = i18n.language;
|
||||
}
|
||||
|
||||
return originalFetch(input, {
|
||||
...init,
|
||||
headers: newHeaders,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -1,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}`
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,6 +476,14 @@
|
|||
</div>
|
||||
|
||||
{#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 />
|
||||
|
||||
{#if promptSuggestions.length > 0}
|
||||
|
|
@ -474,8 +491,8 @@
|
|||
{$i18n.t('Adjusting these settings will apply changes universally to all users.')}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end text-sm font-medium">
|
||||
|
|
|
|||
|
|
@ -4,58 +4,206 @@
|
|||
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';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let banners = [];
|
||||
export let banners: any[] = [];
|
||||
|
||||
let sortable = null;
|
||||
let bannerListElement = null;
|
||||
let sortable: any = null;
|
||||
let bannerListElement: HTMLDivElement | null = null;
|
||||
|
||||
const positionChangeHandler = () => {
|
||||
const bannerIdOrder = Array.from(bannerListElement.children).map((child) =>
|
||||
child.id.replace('banner-item-', '')
|
||||
);
|
||||
// reactive UI language code
|
||||
$: langCode = $i18n?.language?.split('-')[0] || 'de';
|
||||
|
||||
// Sort the banners array based on the new order
|
||||
banners = bannerIdOrder.map((id) => {
|
||||
const index = banners.findIndex((banner) => banner.id === id);
|
||||
return banners[index];
|
||||
});
|
||||
};
|
||||
// dynamic languages from config
|
||||
$: LANGS = Array.isArray($config.features.translation_languages)
|
||||
? [...new Set([...$config.features.translation_languages, langCode])]
|
||||
: [langCode, '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'
|
||||
};
|
||||
// contentObjs stores parsed content objects keyed by banner.id
|
||||
let contentObjs: Record<string, Record<string, string>> = {};
|
||||
|
||||
$: if (banners) {
|
||||
init();
|
||||
// Create a helper function to get/set content safely
|
||||
function getContent(bannerId: string, lang: string): string {
|
||||
ensureContentStructure(bannerId);
|
||||
return contentObjs[bannerId][lang] || '';
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
if (sortable) {
|
||||
sortable.destroy();
|
||||
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 = new Sortable(bannerListElement, {
|
||||
sortable = Sortable.create(bannerListElement, {
|
||||
animation: 150,
|
||||
handle: '.item-handle',
|
||||
onUpdate: async (event) => {
|
||||
positionChangeHandler();
|
||||
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}>
|
||||
<!-- 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}">
|
||||
<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">
|
||||
|
|
@ -73,30 +221,70 @@
|
|||
<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"
|
||||
<!-- 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')}
|
||||
bind:value={banner.content}
|
||||
maxSize={100}
|
||||
/>
|
||||
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">
|
||||
<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;
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
135
src/lib/components/admin/Settings/LangPicker.svelte
Normal file
135
src/lib/components/admin/Settings/LangPicker.svelte
Normal 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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,22 +645,30 @@
|
|||
</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">
|
||||
<div class="mt-2 my-2 flex flex-col">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<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')}
|
||||
bind:value={name}
|
||||
bind:value={titleTranslations[langCode]}
|
||||
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 class="flex-1 w-full">
|
||||
<div class="flex-1">
|
||||
<div>
|
||||
<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')}
|
||||
bind:value={id}
|
||||
disabled={edit}
|
||||
|
|
@ -521,32 +678,13 @@
|
|||
</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 class="my-1">
|
||||
<div class=" text-sm font-medium mb-1">{$i18n.t('Base Model (From)')}</div>
|
||||
|
||||
<div>
|
||||
<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)')}
|
||||
bind:value={info.base_model_id}
|
||||
on:change={(e) => {
|
||||
|
|
@ -565,11 +703,9 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-1">
|
||||
<div class="my-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>
|
||||
<div class=" self-center text-sm font-medium">{$i18n.t('Description')}</div>
|
||||
|
||||
<button
|
||||
class="p-1 text-xs flex rounded-sm transition"
|
||||
|
|
@ -599,7 +735,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-full mb-1 max-w-full">
|
||||
<div class=" mt-2 my-1">
|
||||
<div class="">
|
||||
<Tags
|
||||
tags={info?.meta?.tags ?? []}
|
||||
|
|
@ -618,15 +754,23 @@
|
|||
/>
|
||||
</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/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="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,29 +843,94 @@
|
|||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if info?.meta?.suggestion_prompts}
|
||||
<PromptSuggestions bind:promptSuggestions={info.meta.suggestion_prompts} />
|
||||
{#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>
|
||||
|
||||
<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">
|
||||
<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" />
|
||||
|
||||
{#if $functions.filter((func) => func.type === 'filter').length > 0}
|
||||
<div class="my-2">
|
||||
<FiltersSelector
|
||||
bind:selectedFilterIds={filterIds}
|
||||
|
|
@ -746,19 +955,13 @@
|
|||
</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')}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
|
||||
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -274,6 +274,7 @@ type Config = {
|
|||
enable_autocomplete_generation: boolean;
|
||||
enable_direct_connections: boolean;
|
||||
enable_version_update_check: boolean;
|
||||
translation_languages: string[];
|
||||
};
|
||||
oauth: {
|
||||
providers: {
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
})
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue