Merge branch 'dev' into fix/profile-image-url-validation

This commit is contained in:
Classic298 2026-04-03 21:49:09 +02:00 • committed by GitHub
commit 9af9635751
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 1266 additions and 677 deletions

View file

@ -3940,6 +3940,18 @@ AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT = PersistentConfig(
os.getenv('AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT', 'audio-24khz-160kbitrate-mono-mp3'),
)
AUDIO_TTS_MISTRAL_API_KEY = PersistentConfig(
'AUDIO_TTS_MISTRAL_API_KEY',
'audio.tts.mistral.api_key',
os.getenv('AUDIO_TTS_MISTRAL_API_KEY', ''),
)
AUDIO_TTS_MISTRAL_API_BASE_URL = PersistentConfig(
'AUDIO_TTS_MISTRAL_API_BASE_URL',
'audio.tts.mistral.api_base_url',
os.getenv('AUDIO_TTS_MISTRAL_API_BASE_URL', 'https://api.mistral.ai/v1'),
)
####################################
# LDAP

View file

@ -544,6 +544,12 @@ OAUTH_MAX_SESSIONS_PER_USER = int(os.environ.get('OAUTH_MAX_SESSIONS_PER_USER',
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
ENABLE_OAUTH_TOKEN_EXCHANGE = os.environ.get('ENABLE_OAUTH_TOKEN_EXCHANGE', 'False').lower() == 'true'
# Back-Channel Logout Configuration
# When enabled, exposes POST /oauth/backchannel-logout for IdP-initiated logout
# per OpenID Connect Back-Channel Logout 1.0 spec.
# Requires Redis for JWT revocation.
ENABLE_OAUTH_BACKCHANNEL_LOGOUT = os.environ.get('ENABLE_OAUTH_BACKCHANNEL_LOGOUT', 'False').lower() == 'true'
####################################
# SCIM Configuration
####################################

View file

@ -212,6 +212,8 @@ from open_webui.config import (
AUDIO_TTS_AZURE_SPEECH_REGION,
AUDIO_TTS_AZURE_SPEECH_BASE_URL,
AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT,
AUDIO_TTS_MISTRAL_API_KEY,
AUDIO_TTS_MISTRAL_API_BASE_URL,
PLAYWRIGHT_WS_URL,
PLAYWRIGHT_TIMEOUT,
FIRECRAWL_API_BASE_URL,
@ -511,6 +513,8 @@ from open_webui.env import (
WEBUI_ADMIN_NAME,
ENABLE_EASTER_EGGS,
LOG_FORMAT,
# OAuth Back-Channel Logout
ENABLE_OAUTH_BACKCHANNEL_LOGOUT,
)
@ -1282,6 +1286,9 @@ app.state.config.TTS_AZURE_SPEECH_REGION = AUDIO_TTS_AZURE_SPEECH_REGION
app.state.config.TTS_AZURE_SPEECH_BASE_URL = AUDIO_TTS_AZURE_SPEECH_BASE_URL
app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT
app.state.config.TTS_MISTRAL_API_KEY = AUDIO_TTS_MISTRAL_API_KEY
app.state.config.TTS_MISTRAL_API_BASE_URL = AUDIO_TTS_MISTRAL_API_BASE_URL
app.state.faster_whisper_model = None
app.state.speech_synthesiser = None
@ -2477,6 +2484,21 @@ async def oauth_login_callback(
return await oauth_manager.handle_callback(request, provider, response, db=db)
############################
# OIDC Back-Channel Logout
############################
@app.post('/oauth/backchannel-logout')
async def oauth_backchannel_logout(
request: Request,
db: Session = Depends(get_session),
):
if not ENABLE_OAUTH_BACKCHANNEL_LOGOUT:
raise HTTPException(status_code=404)
return await oauth_manager.handle_backchannel_logout(request, db=db)
@app.get('/manifest.json')
async def get_manifest_json():
if app.state.EXTERNAL_PWA_MANIFEST_URL:

View file

@ -5,6 +5,7 @@ from typing import Any, Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from open_webui.utils.response import normalize_usage
from pydantic import BaseModel, ConfigDict
from sqlalchemy import (
@ -41,6 +42,12 @@ def _normalize_timestamp(timestamp: int) -> float:
return timestamp
def get_usage(data: dict) -> Optional[dict]:
"""Extract and normalize usage from message data."""
usage = data.get('usage') or (data.get('info') or {}).get('usage')
return normalize_usage(usage) if usage else None
####################
# ChatMessage DB Schema
####################
@ -163,11 +170,8 @@ class ChatMessageTable:
existing.status_history = data.get('status_history') or data.get('statusHistory')
if 'error' in data:
existing.error = data.get('error')
# Extract usage - check direct field first, then info.usage
usage = data.get('usage')
if not usage:
info = data.get('info', {})
usage = info.get('usage') if info else None
# Extract and normalize usage
usage = get_usage(data)
if usage:
# Deep-merge: preserve existing keys not present in new data
# This prevents background tasks (follow-ups, title, tags)
@ -179,11 +183,8 @@ class ChatMessageTable:
return ChatMessageModel.model_validate(existing)
else:
# Insert new
# Extract usage - check direct field first, then info.usage
usage = data.get('usage')
if not usage:
info = data.get('info', {})
usage = info.get('usage') if info else None
# Extract and normalize usage
usage = get_usage(data)
message = ChatMessage(
id=composite_id,
chat_id=chat_id,

View file

@ -9,6 +9,7 @@ from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.folders import Folders
from open_webui.models.chat_messages import ChatMessage, ChatMessages
from open_webui.models.automations import AutomationRun
from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db
from pydantic import BaseModel, ConfigDict
@ -826,9 +827,7 @@ class ChatTable:
else:
query = query.order_by(Chat.updated_at.desc(), Chat.id)
query = query.with_entities(
Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at
)
query = query.with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at)
if skip:
query = query.offset(skip)
@ -1262,9 +1261,7 @@ class ChatTable:
query = query.order_by(Chat.updated_at.desc(), Chat.id)
query = query.with_entities(
Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at
)
query = query.with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at)
if skip:
query = query.offset(skip)
@ -1347,9 +1344,7 @@ class ChatTable:
query = query.order_by(Chat.updated_at.desc(), Chat.id)
query = query.with_entities(
Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at
)
query = query.with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at)
if skip:
query = query.offset(skip)
@ -1478,6 +1473,9 @@ class ChatTable:
def delete_chat_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
db.query(AutomationRun).filter_by(chat_id=id).update(
{AutomationRun.chat_id: None}, synchronize_session=False
)
db.query(ChatMessage).filter_by(chat_id=id).delete()
db.query(Chat).filter_by(id=id).delete()
db.commit()
@ -1489,6 +1487,9 @@ class ChatTable:
def delete_chat_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
db.query(AutomationRun).filter_by(chat_id=id).update(
{AutomationRun.chat_id: None}, synchronize_session=False
)
db.query(ChatMessage).filter_by(chat_id=id).delete()
db.query(Chat).filter_by(id=id, user_id=user_id).delete()
db.commit()
@ -1503,6 +1504,9 @@ class ChatTable:
self.delete_shared_chats_by_user_id(user_id, db=db)
chat_id_subquery = db.query(Chat.id).filter_by(user_id=user_id).subquery()
db.query(AutomationRun).filter(AutomationRun.chat_id.in_(chat_id_subquery)).update(
{AutomationRun.chat_id: None}, synchronize_session=False
)
db.query(ChatMessage).filter(ChatMessage.chat_id.in_(chat_id_subquery)).delete(
synchronize_session=False
)
@ -1517,6 +1521,9 @@ class ChatTable:
try:
with get_db_context(db) as db:
chat_id_subquery = db.query(Chat.id).filter_by(user_id=user_id, folder_id=folder_id).subquery()
db.query(AutomationRun).filter(AutomationRun.chat_id.in_(chat_id_subquery)).update(
{AutomationRun.chat_id: None}, synchronize_session=False
)
db.query(ChatMessage).filter(ChatMessage.chat_id.in_(chat_id_subquery)).delete(
synchronize_session=False
)

View file

@ -218,9 +218,7 @@ class FeedbackTable:
# Apply model_id filter (exact match)
model_id = filter.get('model_id')
if model_id:
query = query.filter(
Feedback.data['model_id'].as_string() == model_id
)
query = query.filter(Feedback.data['model_id'].as_string() == model_id)
order_by = filter.get('order_by')
direction = filter.get('direction')

View file

@ -414,6 +414,7 @@ class ModelsTable:
with get_db_context(db) as db:
# update only the fields that are present in the model
data = model.model_dump(exclude={'id', 'access_grants'})
data['updated_at'] = int(time.time())
result = db.query(Model).filter_by(id=id).update(data)
db.commit()
@ -425,6 +426,20 @@ class ModelsTable:
log.exception(f'Failed to update the model by id {id}: {e}')
return None
def update_model_updated_at_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ModelModel]:
try:
with get_db_context(db) as db:
result = db.query(Model).filter_by(id=id).first()
if not result:
return None
result.updated_at = int(time.time())
db.commit()
db.refresh(result)
return self._to_model_model(result, db=db)
except Exception as e:
log.exception(f'Failed to update the model updated_at by id {id}: {e}')
return None
def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:

View file

@ -168,6 +168,8 @@ class TTSConfigForm(BaseModel):
AZURE_SPEECH_REGION: str
AZURE_SPEECH_BASE_URL: str
AZURE_SPEECH_OUTPUT_FORMAT: str
MISTRAL_API_KEY: str
MISTRAL_API_BASE_URL: str
class STTConfigForm(BaseModel):
@ -208,6 +210,8 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION,
'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT,
'MISTRAL_API_KEY': request.app.state.config.TTS_MISTRAL_API_KEY,
'MISTRAL_API_BASE_URL': request.app.state.config.TTS_MISTRAL_API_BASE_URL,
},
'stt': {
'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL,
@ -242,6 +246,8 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm
request.app.state.config.TTS_AZURE_SPEECH_REGION = form_data.tts.AZURE_SPEECH_REGION
request.app.state.config.TTS_AZURE_SPEECH_BASE_URL = form_data.tts.AZURE_SPEECH_BASE_URL
request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = form_data.tts.AZURE_SPEECH_OUTPUT_FORMAT
request.app.state.config.TTS_MISTRAL_API_KEY = form_data.tts.MISTRAL_API_KEY
request.app.state.config.TTS_MISTRAL_API_BASE_URL = form_data.tts.MISTRAL_API_BASE_URL
request.app.state.config.STT_OPENAI_API_BASE_URL = form_data.stt.OPENAI_API_BASE_URL
request.app.state.config.STT_OPENAI_API_KEY = form_data.stt.OPENAI_API_KEY
@ -280,6 +286,8 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm
'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION,
'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT,
'MISTRAL_API_KEY': request.app.state.config.TTS_MISTRAL_API_KEY,
'MISTRAL_API_BASE_URL': request.app.state.config.TTS_MISTRAL_API_BASE_URL,
},
'stt': {
'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL,
@ -551,6 +559,76 @@ async def speech(request: Request, user=Depends(get_verified_user)):
return FileResponse(file_path)
elif request.app.state.config.TTS_ENGINE == 'mistral':
api_key = request.app.state.config.TTS_MISTRAL_API_KEY
api_base_url = request.app.state.config.TTS_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1'
if not api_key:
raise HTTPException(
status_code=400,
detail='Mistral API key is required for Mistral TTS',
)
try:
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
mistral_payload = {
'input': payload.get('input', ''),
'model': request.app.state.config.TTS_MODEL or 'mistral-tts-latest',
'voice_id': payload.get('voice', ''),
'response_format': 'mp3',
}
r = await session.post(
url=f'{api_base_url}/audio/speech',
json=mistral_payload,
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}',
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
r.raise_for_status()
res = await r.json()
audio_data = res.get('audio_data', '')
if not audio_data:
raise ValueError('No audio_data in Mistral TTS response')
audio_bytes = base64.b64decode(audio_data)
async with aiofiles.open(file_path, 'wb') as f:
await f.write(audio_bytes)
async with aiofiles.open(file_body_path, 'w') as f:
await f.write(json.dumps(payload))
return FileResponse(file_path)
except Exception as e:
log.exception(e)
detail = None
status_code = 500
detail = 'Open WebUI: Server Connection Error'
if r is not None:
status_code = r.status
try:
res = await r.json()
if 'error' in res:
detail = f'External: {res["error"]}'
elif 'message' in res:
detail = f'External: {res["message"]}'
except Exception:
detail = f'External: {e}'
raise HTTPException(
status_code=status_code,
detail=detail,
)
def transcription_handler(request, file_path, metadata, user=None):
filename = os.path.basename(file_path)
@ -1238,6 +1316,8 @@ def get_available_models(request: Request) -> list[dict]:
available_models = [{'name': model['name'], 'id': model['model_id']} for model in models]
except requests.RequestException as e:
log.error(f'Error fetching voices: {str(e)}')
elif request.app.state.config.TTS_ENGINE == 'mistral':
available_models = [{'id': 'mistral-tts-latest'}]
return available_models
@ -1301,6 +1381,29 @@ def get_available_voices(request) -> dict:
available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})'
except requests.RequestException as e:
log.error(f'Error fetching voices: {str(e)}')
elif request.app.state.config.TTS_ENGINE == 'mistral':
api_key = request.app.state.config.TTS_MISTRAL_API_KEY
api_base_url = request.app.state.config.TTS_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1'
if api_key:
try:
response = requests.get(
f'{api_base_url}/audio/voices',
headers={
'Authorization': f'Bearer {api_key}',
},
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
voices_data = response.json()
for voice in voices_data:
voice_id = voice.get('voice_id', voice.get('id', ''))
voice_name = voice.get('name', voice_id)
if voice_id:
available_voices[voice_id] = voice_name
except requests.RequestException as e:
log.error(f'Error fetching Mistral voices: {str(e)}')
return available_voices

View file

@ -321,10 +321,7 @@ async def export_all_feedbacks(
):
feedbacks = Feedbacks.get_all_feedbacks(db=db)
if model_id:
feedbacks = [
f for f in feedbacks
if f.data and f.data.get('model_id') == model_id
]
feedbacks = [f for f in feedbacks if f.data and f.data.get('model_id') == model_id]
return feedbacks

View file

@ -207,8 +207,8 @@ def upload_file_handler(
filename = os.path.basename(unsanitized_filename)
file_extension = os.path.splitext(filename)[1]
# Remove the leading dot from the file extension
file_extension = file_extension[1:] if file_extension else ''
# Remove the leading dot from the file extension and lowercase it
file_extension = file_extension[1:].lower() if file_extension else ''
if process and request.app.state.config.ALLOWED_FILE_EXTENSIONS:
request.app.state.config.ALLOWED_FILE_EXTENSIONS = [

View file

@ -578,6 +578,8 @@ async def update_model_access_by_id(
AccessGrants.set_access_grants('model', form_data.id, form_data.access_grants, db=db)
Models.update_model_updated_at_by_id(form_data.id, db=db)
return Models.get_model_by_id(form_data.id, db=db)

View file

@ -206,7 +206,7 @@ def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> st
payload.update({'exp': expire})
jti = str(uuid.uuid4())
payload.update({'jti': jti})
payload.update({'jti': jti, 'iat': datetime.now(UTC)})
encoded_jwt = jwt.encode(payload, SESSION_SECRET, algorithm=ALGORITHM)
return encoded_jwt
@ -221,15 +221,36 @@ def decode_token(token: str) -> Optional[dict]:
async def is_valid_token(request, decoded) -> bool:
# Require Redis to check revoked tokens
"""
Check whether a JWT has been revoked. Two mechanisms:
1. Per-token (jti) — used by user-initiated sign-out (known jti).
2. Per-user (revoked_at) — used by OIDC back-channel logout when
individual jti values are unknown; rejects tokens with iat <= revoked_at.
"""
if request.app.state.redis:
# Per-token revocation
jti = decoded.get('jti')
if jti:
revoked = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked')
if revoked:
return False
# Per-user revocation (OIDC back-channel logout)
user_id = decoded.get('id')
if user_id:
revoked_at = await request.app.state.redis.get(
f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at'
)
if revoked_at:
try:
revoked_at_ts = int(revoked_at)
token_iat = decoded.get('iat')
# No iat means legacy token — reject since we can't verify issue time
if token_iat is None or token_iat <= revoked_at_ts:
return False
except (ValueError, TypeError):
pass
return True

View file

@ -75,6 +75,7 @@ from open_webui.env import (
ENABLE_OAUTH_EMAIL_FALLBACK,
OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
OAUTH_MAX_SESSIONS_PER_USER,
REDIS_KEY_PREFIX,
)
from open_webui.utils.misc import parse_duration
from open_webui.utils.auth import get_password_hash, create_token
@ -1357,9 +1358,8 @@ class OAuthManager:
client = self.get_client(provider)
if client is None:
raise HTTPException(404)
redirect_uri = (
(client.server_metadata or {}).get('redirect_uri')
or request.url_for('oauth_login_callback', provider=provider)
redirect_uri = (client.server_metadata or {}).get('redirect_uri') or request.url_for(
'oauth_login_callback', provider=provider
)
kwargs = {}
@ -1694,3 +1694,181 @@ class OAuthManager:
log.error(f'Failed to store OAuth session server-side: {e}')
return response
async def handle_backchannel_logout(self, request, db=None):
"""
Handle an OIDC Back-Channel Logout request.
Validates the logout_token, identifies the user, revokes their
sessions via Redis, and deletes their OAuth sessions.
Returns a JSONResponse per the OIDC Back-Channel Logout 1.0 spec.
"""
import jwt as pyjwt
from fastapi.responses import JSONResponse
# 1. Extract logout_token from form body
try:
form = await request.form()
logout_token = form.get('logout_token')
except Exception:
logout_token = None
if not logout_token:
return JSONResponse(
status_code=400,
content={'error': 'invalid_request', 'error_description': 'Missing logout_token parameter'},
)
# 2. Peek at unverified issuer to match against configured providers
try:
unverified_claims = pyjwt.decode(logout_token, options={'verify_signature': False})
token_issuer = unverified_claims.get('iss')
except Exception as e:
log.warning(f'Back-channel logout: cannot decode logout_token: {e}')
return JSONResponse(
status_code=400,
content={'error': 'invalid_request', 'error_description': 'Malformed logout_token'},
)
if not token_issuer:
return JSONResponse(
status_code=400,
content={'error': 'invalid_request', 'error_description': 'logout_token missing iss claim'},
)
# 3. Find the configured provider whose issuer matches the token
matched_provider = None
matched_client_id = None
matched_jwks_uri = None
matched_issuer = None
for provider_name in OAUTH_PROVIDERS:
server_metadata_url = self.get_server_metadata_url(provider_name)
if not server_metadata_url:
continue
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(server_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r:
if r.status != 200:
continue
oidc_config = await r.json()
provider_issuer = oidc_config.get('issuer')
if provider_issuer and provider_issuer == token_issuer:
client = self.get_client(provider_name)
matched_provider = provider_name
matched_client_id = client.client_id if client else None
matched_jwks_uri = oidc_config.get('jwks_uri')
matched_issuer = provider_issuer
break
except Exception as e:
log.debug(f'Back-channel logout: error checking provider {provider_name}: {e}')
continue
if not matched_provider or not matched_client_id or not matched_jwks_uri:
log.warning(f'Back-channel logout: no configured provider matches issuer {token_issuer}')
return JSONResponse(
status_code=400,
content={'error': 'invalid_request', 'error_description': 'No configured provider matches token issuer'},
)
# 4. Validate the logout_token signature and claims
try:
jwks_client = pyjwt.PyJWKClient(matched_jwks_uri)
signing_key = jwks_client.get_signing_key_from_jwt(logout_token)
claims = pyjwt.decode(
logout_token,
signing_key.key,
algorithms=['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'],
audience=matched_client_id,
issuer=matched_issuer,
options={
'require': ['iss', 'aud', 'iat', 'events'],
},
)
except pyjwt.InvalidTokenError as e:
log.warning(f'Back-channel logout: invalid logout_token: {e}')
return JSONResponse(
status_code=400,
content={'error': 'invalid_request', 'error_description': f'Invalid logout_token: {e}'},
)
except Exception as e:
log.error(f'Back-channel logout: error validating logout_token: {e}')
return JSONResponse(
status_code=400,
content={'error': 'invalid_request', 'error_description': 'Failed to validate logout_token'},
)
# 5. Validate events claim per spec
events = claims.get('events', {})
if 'http://schemas.openid.net/event/backchannel-logout' not in events:
log.warning('Back-channel logout: missing required backchannel-logout event claim')
return JSONResponse(
status_code=400,
content={'error': 'invalid_request', 'error_description': 'Missing backchannel-logout event claim'},
)
# 6. Per spec, back-channel logout tokens MUST NOT contain a nonce
if 'nonce' in claims:
log.warning('Back-channel logout: logout_token contains nonce (rejected per spec)')
return JSONResponse(
status_code=400,
content={'error': 'invalid_request', 'error_description': 'logout_token must not contain nonce'},
)
# 7. Extract sub and/or sid — at least one must be present
sub = claims.get('sub')
sid = claims.get('sid')
if not sub and not sid:
log.warning('Back-channel logout: logout_token contains neither sub nor sid')
return JSONResponse(
status_code=400,
content={'error': 'invalid_request', 'error_description': 'logout_token must contain sub or sid'},
)
# 8. Identify users to log out
users_to_logout = []
if sub:
user = Users.get_user_by_oauth_sub(matched_provider, sub, db=db)
if user:
users_to_logout.append(user)
if not users_to_logout and sid:
log.info(f'Back-channel logout: no user found by sub, sid-based lookup not yet supported (sid={sid})')
if not users_to_logout:
log.info(f'Back-channel logout: no matching user for provider={matched_provider}, sub={sub}, sid={sid}')
return JSONResponse(status_code=200, content={})
# 9. Revoke tokens and delete sessions
redis = request.app.state.redis
if not redis:
log.warning(
'Back-channel logout: Redis not configured, cannot revoke JWT tokens. '
'OAuth sessions will be deleted but existing JWTs will remain valid until expiry.'
)
revoked_count = 0
for user in users_to_logout:
sessions = OAuthSessions.get_sessions_by_user_id(user.id, db=db)
for oauth_session in sessions:
OAuthSessions.delete_session_by_id(oauth_session.id, db=db)
if redis:
revocation_key = f'{REDIS_KEY_PREFIX}:auth:user:{user.id}:revoked_at'
await redis.set(
revocation_key,
str(int(time.time())),
ex=60 * 60 * 24 * 30,
)
revoked_count += 1
log.info(
f'Back-channel logout: revoked sessions for user {user.id} '
f'(email={user.email}, provider={matched_provider}, sessions_deleted={len(sessions)})'
)
log.info(f'Back-channel logout: completed for {len(users_to_logout)} user(s), {revoked_count} revocation(s) set')
return JSONResponse(status_code=200, content={})

View file

@ -414,7 +414,7 @@ export const updateUserProfile = async (token: string, profile: object) => {
console.error(err);
error = err.detail;
if (Array.isArray(error)) {
error = error.map((e: { msg?: string }) => e.msg).join("; ");
error = error.map((e: { msg?: string }) => e.msg).join('; ');
}
return null;
});

View file

@ -189,7 +189,13 @@ export const getFeedbackModelIds = async (token: string = '') => {
return res;
};
export const getFeedbackItems = async (token: string = '', orderBy, direction, page, modelId: string = '') => {
export const getFeedbackItems = async (
token: string = '',
orderBy,
direction,
page,
modelId: string = ''
) => {
let error = null;
const searchParams = new URLSearchParams();
@ -235,14 +241,17 @@ export const exportAllFeedbacks = async (token: string = '', modelId: string = '
const searchParams = new URLSearchParams();
if (modelId) searchParams.append('model_id', modelId);
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedbacks/all/export?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
const res = await fetch(
`${WEBUI_API_BASE_URL}/evaluations/feedbacks/all/export?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
})
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();

View file

@ -176,10 +176,9 @@
: s;
};
return [
headers.join(','),
...rows.map((r) => headers.map((h) => escape(r[h])).join(','))
].join('\n');
return [headers.join(','), ...rows.map((r) => headers.map((h) => escape(r[h])).join(','))].join(
'\n'
);
};
const exportHandler = async (format: 'json' | 'csv' = 'json') => {

View file

@ -37,6 +37,8 @@
let TTS_AZURE_SPEECH_REGION = '';
let TTS_AZURE_SPEECH_BASE_URL = '';
let TTS_AZURE_SPEECH_OUTPUT_FORMAT = '';
let TTS_MISTRAL_API_KEY = '';
let TTS_MISTRAL_API_BASE_URL = '';
let STT_OPENAI_API_BASE_URL = '';
let STT_OPENAI_API_KEY = '';
@ -124,6 +126,8 @@
AZURE_SPEECH_REGION: TTS_AZURE_SPEECH_REGION,
AZURE_SPEECH_BASE_URL: TTS_AZURE_SPEECH_BASE_URL,
AZURE_SPEECH_OUTPUT_FORMAT: TTS_AZURE_SPEECH_OUTPUT_FORMAT,
MISTRAL_API_KEY: TTS_MISTRAL_API_KEY,
MISTRAL_API_BASE_URL: TTS_MISTRAL_API_BASE_URL,
SPLIT_ON: TTS_SPLIT_ON
},
stt: {
@ -176,6 +180,8 @@
TTS_AZURE_SPEECH_REGION = res.tts.AZURE_SPEECH_REGION;
TTS_AZURE_SPEECH_BASE_URL = res.tts.AZURE_SPEECH_BASE_URL;
TTS_AZURE_SPEECH_OUTPUT_FORMAT = res.tts.AZURE_SPEECH_OUTPUT_FORMAT;
TTS_MISTRAL_API_KEY = res.tts.MISTRAL_API_KEY;
TTS_MISTRAL_API_BASE_URL = res.tts.MISTRAL_API_BASE_URL;
STT_OPENAI_API_BASE_URL = res.stt.OPENAI_API_BASE_URL;
STT_OPENAI_API_KEY = res.stt.OPENAI_API_KEY;
@ -517,6 +523,9 @@
if (e.target?.value === 'openai') {
TTS_VOICE = 'alloy';
TTS_MODEL = 'tts-1';
} else if (e.target?.value === 'mistral') {
TTS_VOICE = '';
TTS_MODEL = 'mistral-tts-latest';
} else {
TTS_VOICE = '';
TTS_MODEL = '';
@ -528,6 +537,7 @@
<option value="openai">{$i18n.t('OpenAI')}</option>
<option value="elevenlabs">{$i18n.t('ElevenLabs')}</option>
<option value="azure">{$i18n.t('Azure AI Speech')}</option>
<option value="mistral">{$i18n.t('MistralAI')}</option>
</select>
</div>
</div>
@ -585,6 +595,19 @@
</div>
</div>
</div>
{:else if TTS_ENGINE === 'mistral'}
<div>
<div class="mt-1 flex gap-2 mb-1">
<input
class="flex-1 w-full bg-transparent outline-hidden"
placeholder={$i18n.t('API Base URL')}
bind:value={TTS_MISTRAL_API_BASE_URL}
required
/>
<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={TTS_MISTRAL_API_KEY} />
</div>
</div>
{/if}
<div class="mb-2">
@ -791,6 +814,47 @@
</div>
</div>
</div>
{:else if TTS_ENGINE === 'mistral'}
<div class=" flex gap-2">
<div class="w-full">
<div class=" mb-1.5 text-xs font-medium">{$i18n.t('TTS Voice')}</div>
<div class="flex w-full">
<div class="flex-1">
<input
list="voice-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={TTS_VOICE}
placeholder={$i18n.t('Select a voice')}
/>
<datalist id="voice-list">
{#each voices as voice}
<option value={voice.id}>{voice.name}</option>
{/each}
</datalist>
</div>
</div>
</div>
<div class="w-full">
<div class=" mb-1.5 text-xs font-medium">{$i18n.t('TTS Model')}</div>
<div class="flex w-full">
<div class="flex-1">
<input
list="tts-model-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={TTS_MODEL}
placeholder={$i18n.t('Select a model')}
/>
<datalist id="tts-model-list">
{#each models as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700" />
{/each}
</datalist>
</div>
</div>
</div>
</div>
{/if}
</div>

View file

@ -276,7 +276,7 @@
{#if isDirty}
<button
class="px-4 py-1.5 text-sm bg-black text-white dark:bg-white dark:text-black rounded-full hover:opacity-90 transition flex items-center gap-1.5"
class="px-3 py-1 text-sm bg-black text-white dark:bg-white dark:text-black rounded-full hover:opacity-90 transition flex items-center gap-1.5"
on:click={saveHandler}
disabled={saving}
type="button"
@ -289,7 +289,7 @@
{/if}
<button
class="px-4 py-1.5 text-sm border border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full flex items-center gap-1.5"
class="px-2.5 py-1 text-sm border border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full flex items-center gap-1.5"
on:click={runNowHandler}
type="button"
disabled={loading}
@ -334,7 +334,7 @@
</div>
<!-- Sidebar Configuration Column -->
<div class="hidden md:flex w-full md:w-80 shrink-0 overflow-y-auto px-1 flex-col gap-5">
<div class="hidden md:flex w-full md:w-66 shrink-0 overflow-y-auto px-1 flex-col gap-5">
<div>
<div class="text-gray-500 text-xs mb-3">{$i18n.t('Configuration')}</div>
<div class="space-y-1">
@ -378,18 +378,18 @@
<div class="space-y-2.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('State')}</span>
<div
class="flex items-center gap-1.5 px-2.5 py-1 rounded-xl text-xs transition {is_active
? 'text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800'}"
<span
class="flex items-center gap-1.5 text-xs {is_active
? 'text-emerald-700 dark:text-emerald-400'
: 'text-gray-600 dark:text-gray-400'}"
>
<span
class="inline-block size-1.5 rounded-full {is_active
? 'bg-emerald-500'
: 'bg-gray-400'}"
></span>
<span>{is_active ? $i18n.t('Active') : $i18n.t('Paused')}</span>
</div>
{is_active ? $i18n.t('Active') : $i18n.t('Paused')}
</span>
</div>
<div class="flex items-center justify-between text-xs">
@ -410,8 +410,8 @@
</div>
</div>
<div class="flex-1 flex flex-col min-h-0">
<div class="text-gray-500 text-xs mb-2 shrink-0">
<div class="flex-1 flex flex-col min-h-0 -mx-1">
<div class="text-gray-500 text-xs mb-2 mx-1 shrink-0">
{$i18n.t('Execution Logs')}
</div>
<div class="flex-1 overflow-y-auto scrollbar-hidden w-full" on:scroll={onScroll}>
@ -427,7 +427,7 @@
<div class="space-y-0.5 w-full">
{#each runs as run (run.id)}
<button
class="w-full text-left flex items-center gap-2.5 px-2.5 py-2 rounded-xl hover:bg-gray-100/80 dark:hover:bg-gray-850/80 transition-colors {run.chat_id
class="w-full text-left flex items-center gap-2.5 px-2.5 py-1.5 rounded-xl hover:bg-gray-100/80 dark:hover:bg-gray-850/80 transition-colors {run.chat_id
? 'cursor-pointer'
: 'cursor-default'}"
on:click={() => {

View file

@ -193,11 +193,11 @@
slot="content"
class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-48 p-1"
>
<div class="px-2 text-xs text-gray-500 py-1">
<div class="px-2 text-xs text-gray-500 pt-1">
{$i18n.t('Schedule')}
</div>
<div class="px-1.5 mt-0.5 mb-2">
<div class="px-1.5 py-0.5">
<select
class="w-full bg-transparent rounded-xl text-xs py-1.5 px-1.5 outline-hidden"
bind:value={frequency}

View file

@ -2417,7 +2417,7 @@
history.messages[responseMessage.id] = responseMessage;
};
const stopResponse = async () => {
const stopResponse = async (processQueue = true) => {
if (taskIds) {
for (const taskId of taskIds) {
const res = await stopTask(localStorage.token, taskId).catch((error) => {
@ -2449,7 +2449,9 @@
generationController = null;
}
await processNextInQueue($chatId);
if (processQueue) {
await processNextInQueue($chatId);
}
};
const submitMessage = async (parentId, prompt) => {
@ -2913,10 +2915,8 @@
...q,
[$chatId]: queue.filter((m) => m.id !== id)
}));
// Stop current generation first
await stopResponse();
await stopResponse(false);
await tick();
// Submit queued message directly without clearing input
await submitPrompt(item.prompt, item.files);
}
}}

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { config, user, models as _models, temporaryChatEnabled } from '$lib/stores';
import { onMount, getContext } from 'svelte';
@ -96,10 +97,12 @@
<div
class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400 line-clamp-3 markdown"
>
{@html marked.parse(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description
).replaceAll('\n', '<br>')
{@html DOMPurify.sanitize(
marked.parse(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description
).replaceAll('\n', '<br>')
)
)}
</div>
{#if models[selectedModelIdx]?.info?.meta?.user}

View file

@ -42,7 +42,6 @@
let contentContainerElement;
let floatingButtonsElement;
let sourceIds = [];
$: getSourceIds(sources);

View file

@ -39,9 +39,9 @@
aria-label={collapsed ? 'Expand' : 'Collapse'}
>
{#if collapsed}
<ChevronDown className="w-2.5 h-2.5" />
{:else}
<ChevronUp className="w-2.5 h-2.5" />
{:else}
<ChevronDown className="w-2.5 h-2.5" />
{/if}
</button>
</div>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { marked } from 'marked';
import Fuse from 'fuse.js';
@ -63,6 +62,64 @@
let tagsContainerElement;
let show = false;
let triggerElement: HTMLElement | null = null;
let contentElement: HTMLElement | null = null;
let dropdownPosition = { top: 0, left: 0, width: 0 };
const portal = (node: HTMLElement) => {
document.body.appendChild(node);
return {
destroy() {
node.remove();
}
};
};
const updatePosition = () => {
if (!show || !triggerElement) return;
const rect = triggerElement.getBoundingClientRect();
dropdownPosition = {
top: rect.bottom + 2,
left: $mobile ? 8 : rect.left,
width: $mobile ? window.innerWidth - 16 : 0
};
};
const toggleOpen = () => {
show = !show;
if (show) {
searchValue = '';
listScrollTop = 0;
resetView();
updatePosition();
window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0);
} else {
document.getElementById(`model-selector-${id}-button`)?.blur();
}
};
const handlePointerDown = (e: PointerEvent) => {
if (!show) return;
const target = e.target as Node;
if (
(triggerElement && triggerElement.contains(target)) ||
(contentElement && contentElement.contains(target))
) {
return;
}
show = false;
document.getElementById(`model-selector-${id}-button`)?.blur();
};
const handleKeydown = (e: KeyboardEvent) => {
if (show && e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
show = false;
document.getElementById(`model-selector-${id}-button`)?.blur();
}
};
let tags = [];
let selectedModel = '';
@ -437,30 +494,26 @@
}}
/>
<DropdownMenu.Root
bind:open={show}
onOpenChange={async () => {
searchValue = '';
listScrollTop = 0;
window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0);
<svelte:window
on:pointerdown={handlePointerDown}
on:keydown={handleKeydown}
on:resize={updatePosition}
/>
resetView();
}}
onOpenChangeComplete={(open) => {
if (!open) {
// Replaces the old closeFocus={false} behavior - prevent focus jump back to trigger
document.getElementById(`model-selector-${id}-button`)?.blur();
}
}}
>
<DropdownMenu.Trigger
<div class="relative w-full">
<button
bind:this={triggerElement}
class="relative w-full {($settings?.highContrastMode ?? false)
? ''
: 'outline-hidden focus:outline-hidden'}"
aria-label={selectedModel
? $i18n.t('Selected model: {{modelName}}', { modelName: selectedModel.label })
: placeholder}
aria-haspopup="listbox"
aria-expanded={show}
id="model-selector-{id}-button"
type="button"
on:click={toggleOpen}
>
<div
class="flex w-full text-left px-0.5 bg-transparent truncate {triggerClassName} justify-between {($settings?.highContrastMode ??
@ -483,325 +536,313 @@
{/if}
<ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" />
</div>
</DropdownMenu.Trigger>
</button>
<DropdownMenu.Portal>
<DropdownMenu.Content
forceMount
trapFocus={false}
preventScroll={false}
side="bottom"
align={$mobile ? 'center' : 'start'}
sideOffset={2}
alignOffset={-1}
{#if show}
<div
use:portal
bind:this={contentElement}
style="position: fixed; z-index: 9999; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px;{$mobile
? ` width: ${dropdownPosition.width}px;`
: ''}"
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps} style="{wrapperProps.style ?? ''}{$mobile ? '; left: 0.5rem !important; width: calc(100vw - 1rem) !important;' : ''}">
<div
{...props}
class="{props.class} z-40 {$mobile
? `w-full`
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-2xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-hidden"
transition:flyAndScale
>
<slot>
{#if searchEnabled}
<div class="flex items-center gap-2.5 px-4.5 pt-3.5 mb-1.5">
<Search className="size-4" strokeWidth="2.5" />
<div
class="z-40 {$mobile
? `w-full`
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-2xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-hidden"
transition:flyAndScale
>
<slot>
{#if searchEnabled}
<div class="flex items-center gap-2.5 px-4.5 pt-3.5 mb-1.5">
<Search className="size-4" strokeWidth="2.5" />
<input
id="model-search-input"
bind:value={searchValue}
class="w-full text-sm bg-transparent outline-hidden"
placeholder={searchPlaceholder}
autocomplete="off"
aria-label={$i18n.t('Search In Models')}
on:keydown={(e) => {
if (e.code === 'Enter' && filteredItems.length > 0) {
value = filteredItems[selectedModelIdx].value;
show = false;
return; // dont need to scroll on selection
} else if (e.code === 'ArrowDown') {
e.stopPropagation();
selectedModelIdx = Math.min(
selectedModelIdx + 1,
filteredItems.length - 1
);
} else if (e.code === 'ArrowUp') {
e.stopPropagation();
selectedModelIdx = Math.max(selectedModelIdx - 1, 0);
} else {
// if the user types something, reset to the top selection.
selectedModelIdx = 0;
}
<input
id="model-search-input"
bind:value={searchValue}
class="w-full text-sm bg-transparent outline-hidden"
placeholder={searchPlaceholder}
autocomplete="off"
aria-label={$i18n.t('Search In Models')}
on:keydown={(e) => {
if (e.code === 'Enter' && filteredItems.length > 0) {
value = filteredItems[selectedModelIdx].value;
show = false;
return; // dont need to scroll on selection
} else if (e.code === 'ArrowDown') {
e.stopPropagation();
selectedModelIdx = Math.min(selectedModelIdx + 1, filteredItems.length - 1);
} else if (e.code === 'ArrowUp') {
e.stopPropagation();
selectedModelIdx = Math.max(selectedModelIdx - 1, 0);
} else {
// if the user types something, reset to the top selection.
selectedModelIdx = 0;
}
const item = document.querySelector(`[data-arrow-selected="true"]`);
item?.scrollIntoView({
block: 'center',
inline: 'nearest',
behavior: 'instant'
});
}}
/>
</div>
{/if}
const item = document.querySelector(`[data-arrow-selected="true"]`);
item?.scrollIntoView({
block: 'center',
inline: 'nearest',
behavior: 'instant'
});
}}
/>
</div>
{/if}
<div class="px-2">
{#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0}
<div
class=" flex w-full bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none font-[450] mb-0.5"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
<div class="px-2">
{#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0}
<div
class=" flex w-full bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none font-[450] mb-0.5"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-1 w-fit text-center text-sm rounded-full bg-transparent px-1.5 whitespace-nowrap"
bind:this={tagsContainerElement}
>
{#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === '' &&
selectedConnectionType === ''
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === '' && selectedConnectionType === ''}
on:click={() => {
selectedConnectionType = '';
selectedTag = '';
}}
>
<div
class="flex gap-1 w-fit text-center text-sm rounded-full bg-transparent px-1.5 whitespace-nowrap"
bind:this={tagsContainerElement}
>
{#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === '' &&
selectedConnectionType === ''
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === '' && selectedConnectionType === ''}
on:click={() => {
selectedConnectionType = '';
selectedTag = '';
}}
>
{$i18n.t('All')}
</button>
{/if}
{#if items.find((item) => item.model?.connection_type === 'local')}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
'local'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'local'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'local';
}}
>
{$i18n.t('Local')}
</button>
{/if}
{#if items.find((item) => item.model?.connection_type === 'external')}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
'external'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'external'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'external';
}}
>
{$i18n.t('External')}
</button>
{/if}
{#if items.find((item) => item.model?.direct)}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
'direct'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'direct'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'direct';
}}
>
{$i18n.t('Direct')}
</button>
{/if}
{#each tags as tag}
<Tooltip content={tag}>
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === tag
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === tag}
on:click={() => {
selectedConnectionType = '';
selectedTag = tag;
}}
>
{tag.length > 16 ? `${tag.slice(0, 16)}...` : tag}
</button>
</Tooltip>
{/each}
</div>
</div>
{/if}
</div>
<div class="px-2.5 group relative">
{#if filteredItems.length === 0}
{#if items.length === 0 && $user?.role === 'admin'}
<div class="flex flex-col items-start justify-center py-6 px-4 text-start">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{$i18n.t('No models available')}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-4">
{$i18n.t('Connect to an AI provider to start chatting')}
</div>
<a
href="/admin/settings/connections"
class="px-4 py-1.5 rounded-xl text-xs font-medium bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 transition"
on:click={() => {
show = false;
}}
>
{$i18n.t('Manage Connections')}
</a>
</div>
{:else}
<div class="">
<div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
{$i18n.t('No results found')}
</div>
</div>
{/if}
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="max-h-64 overflow-y-auto"
role="listbox"
aria-label={$i18n.t('Available models')}
bind:this={listContainer}
on:scroll={() => {
listScrollTop = listContainer.scrollTop;
}}
>
<div style="height: {visibleStart * ITEM_HEIGHT}px;" />
{#each filteredItems.slice(visibleStart, visibleEnd) as item, i (item.value)}
{@const index = visibleStart + i}
<ModelItem
{selectedModelIdx}
{item}
{index}
{value}
{pinModelHandler}
{unloadModelHandler}
{deleteModelHandler}
onClick={() => {
value = item.value;
selectedModelIdx = index;
show = false;
}}
/>
{/each}
<div style="height: {(filteredItems.length - visibleEnd) * ITEM_HEIGHT}px;" />
</div>
{$i18n.t('All')}
</button>
{/if}
{#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user?.role === 'admin'}
<Tooltip
content={$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
searchValue: searchValue
})}
placement="top-start"
{#if items.find((item) => item.model?.connection_type === 'local')}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
'local'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'local'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'local';
}}
>
{$i18n.t('Local')}
</button>
{/if}
{#if items.find((item) => item.model?.connection_type === 'external')}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
'external'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'external'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'external';
}}
>
{$i18n.t('External')}
</button>
{/if}
{#if items.find((item) => item.model?.direct)}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
'direct'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'direct'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'direct';
}}
>
{$i18n.t('Direct')}
</button>
{/if}
{#each tags as tag}
<Tooltip content={tag}>
<button
class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl cursor-pointer data-highlighted:bg-muted"
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === tag
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === tag}
on:click={() => {
pullModelHandler();
selectedConnectionType = '';
selectedTag = tag;
}}
>
<div class=" truncate">
{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
searchValue: searchValue
})}
</div>
{tag.length > 16 ? `${tag.slice(0, 16)}...` : tag}
</button>
</Tooltip>
{/if}
{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
<div
class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 rounded-xl cursor-pointer data-highlighted:bg-muted"
>
<div class="flex">
<div class="mr-2.5 translate-y-0.5">
<Spinner />
</div>
<div class="flex flex-col self-start">
<div class="flex gap-1">
<div class="line-clamp-1">
Downloading "{model}"
</div>
<div class="shrink-0">
{'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
: ''}
</div>
</div>
{#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest}
<div class="-mt-1 h-fit text-[0.7rem] dark:text-gray-500 line-clamp-1">
{$MODEL_DOWNLOAD_POOL[model].digest}
</div>
{/if}
</div>
</div>
<div class="mr-2 ml-1 translate-y-0.5">
<Tooltip content={$i18n.t('Cancel')}>
<button
class="text-gray-800 dark:text-gray-100"
aria-label={$i18n.t('Cancel download of {{model}}', { model: model })}
on:click={() => {
cancelModelPullHandler(model);
}}
>
<svg
class="w-4 h-4 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18 17.94 6M18 18 6.06 6"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
{/each}
</div>
<div class="pb-2.5"></div>
<div class="hidden w-[42rem]" />
<div class="hidden w-[32rem]" />
</slot>
</div>
</div>
{/if}
</div>
{/if}
{/snippet}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<div class="px-2.5 group relative">
{#if filteredItems.length === 0}
{#if items.length === 0 && $user?.role === 'admin'}
<div class="flex flex-col items-start justify-center py-6 px-4 text-start">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{$i18n.t('No models available')}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-4">
{$i18n.t('Connect to an AI provider to start chatting')}
</div>
<a
href="/admin/settings/connections"
class="px-4 py-1.5 rounded-xl text-xs font-medium bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 transition"
on:click={() => {
show = false;
}}
>
{$i18n.t('Manage Connections')}
</a>
</div>
{:else}
<div class="">
<div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
{$i18n.t('No results found')}
</div>
</div>
{/if}
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="max-h-64 overflow-y-auto"
role="listbox"
aria-label={$i18n.t('Available models')}
bind:this={listContainer}
on:scroll={() => {
listScrollTop = listContainer.scrollTop;
}}
>
<div style="height: {visibleStart * ITEM_HEIGHT}px;" />
{#each filteredItems.slice(visibleStart, visibleEnd) as item, i (item.value)}
{@const index = visibleStart + i}
<ModelItem
{selectedModelIdx}
{item}
{index}
{value}
{pinModelHandler}
{unloadModelHandler}
{deleteModelHandler}
onClick={() => {
value = item.value;
selectedModelIdx = index;
show = false;
}}
/>
{/each}
<div style="height: {(filteredItems.length - visibleEnd) * ITEM_HEIGHT}px;" />
</div>
{/if}
{#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user?.role === 'admin'}
<Tooltip
content={$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
searchValue: searchValue
})}
placement="top-start"
>
<button
class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl cursor-pointer data-highlighted:bg-muted"
on:click={() => {
pullModelHandler();
}}
>
<div class=" truncate">
{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
searchValue: searchValue
})}
</div>
</button>
</Tooltip>
{/if}
{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
<div
class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 rounded-xl cursor-pointer data-highlighted:bg-muted"
>
<div class="flex">
<div class="mr-2.5 translate-y-0.5">
<Spinner />
</div>
<div class="flex flex-col self-start">
<div class="flex gap-1">
<div class="line-clamp-1">
Downloading "{model}"
</div>
<div class="shrink-0">
{'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
: ''}
</div>
</div>
{#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest}
<div class="-mt-1 h-fit text-[0.7rem] dark:text-gray-500 line-clamp-1">
{$MODEL_DOWNLOAD_POOL[model].digest}
</div>
{/if}
</div>
</div>
<div class="mr-2 ml-1 translate-y-0.5">
<Tooltip content={$i18n.t('Cancel')}>
<button
class="text-gray-800 dark:text-gray-100"
aria-label={$i18n.t('Cancel download of {{model}}', { model: model })}
on:click={() => {
cancelModelPullHandler(model);
}}
>
<svg
class="w-4 h-4 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18 17.94 6M18 18 6.06 6"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
{/each}
</div>
<div class="pb-2.5"></div>
<div class="hidden w-[42rem]" />
<div class="hidden w-[32rem]" />
</slot>
</div>
</div>
{/if}
</div>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { onMount, getContext, tick, createEventDispatcher } from 'svelte';
import { blur, fade } from 'svelte/transition';
@ -174,10 +175,12 @@
<div
class="mt-0.5 px-2 text-sm font-normal text-gray-500 dark:text-gray-400 line-clamp-2 max-w-xl markdown"
>
{@html marked.parse(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
{@html DOMPurify.sanitize(
marked.parse(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)
)}
</div>
</Tooltip>

View file

@ -603,7 +603,7 @@
<div
role="tablist"
id="settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 mx-3 md:pr-4 md:gap-1 md:flex-col flex-1 md:flex-none md:w-50 md:min-h-[42rem] md:max-h-[42rem] dark:text-gray-200 text-sm text-left mb-1 md:mb-0 -translate-y-1"
class="tabs flex flex-row overflow-x-auto gap-2.5 mx-3 md:pr-4 md:gap-1 md:flex-col flex-1 md:flex-none md:w-50 md:min-h-[min(42rem,calc(100dvh-10rem))] md:max-h-[min(42rem,calc(100dvh-10rem))] dark:text-gray-200 text-sm text-left mb-1 md:mb-0 -translate-y-1"
>
<div
class="hidden md:flex w-full rounded-full px-2.5 gap-2 bg-gray-100/80 dark:bg-gray-850/80 backdrop-blur-2xl my-1 mb-1.5"
@ -874,7 +874,9 @@
</a>
{/if}
</div>
<div class="flex-1 px-3.5 md:pl-0 md:pr-4.5 md:min-h-[42rem] max-h-[42rem]">
<div
class="flex-1 px-3.5 md:pl-0 md:pr-4.5 md:min-h-[min(42rem,calc(100dvh-10rem))] max-h-[min(42rem,calc(100dvh-10rem))] overflow-y-auto"
>
{#if selectedTab === 'general'}
<General
{getModels}

View file

@ -41,10 +41,8 @@
style="white-space: pre-wrap;"
>
{#if ($config?.ui?.pending_user_overlay_content ?? '').trim() !== ''}
{@html marked.parse(
DOMPurify.sanitize(
($config?.ui?.pending_user_overlay_content ?? '').replace(/\n/g, '<br>')
)
{@html DOMPurify.sanitize(
marked.parse(($config?.ui?.pending_user_overlay_content ?? '').replace(/\n/g, '<br>'))
)}
{:else}
{$i18n.t('Your account status is currently pending activation.')}{'\n'}{$i18n.t(

View file

@ -10,7 +10,7 @@
import { splitStream } from '$lib/utils';
import Spinner from '$lib/components/common/Spinner.svelte';
import Selector from '$lib/components/chat/ModelSelector/Selector.svelte';
const i18n = getContext('i18n');
@ -123,26 +123,6 @@
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
<div class="mx-auto w-full md:px-0 h-full">
<div class=" flex flex-col h-full px-4">
<div class="flex flex-col justify-between mb-1 gap-1">
<div class="flex flex-col gap-1 w-full">
<div class="flex w-full">
<div class="overflow-hidden w-full">
<div class="max-w-full">
<Selector
placeholder={$i18n.t('Select a model')}
items={$models.map((model) => ({
value: model.id,
label: model.name,
model: model
}))}
bind:value={selectedModelId}
/>
</div>
</div>
</div>
</div>
</div>
<div
class=" pt-0.5 pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
id="messages-container"
@ -160,8 +140,19 @@
</div>
</div>
<div class="pb-3 flex justify-end">
<div class="flex gap-2 shrink-0">
<div class="pb-3 flex justify-between items-center">
<div class="flex-1">
<select
class="bg-transparent border border-gray-100/30 dark:border-gray-850/30 rounded-lg py-1 px-2 -mx-0.5 text-sm outline-hidden w-full"
bind:value={selectedModelId}
>
{#each $models as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
</div>
<div class="flex gap-2 shrink-0 ml-2">
{#if !loading}
<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-lg"

View file

@ -850,6 +850,8 @@
"Export": "تصدير",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "تصدير جميع الدردشات (جميع المستخدمين)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",
"Export Chats": "تصدير جميع الدردشات",
"Export Config": "",

View file

@ -850,6 +850,8 @@
"Export": "تصدير",
"Export All Archived Chats": "تصدير جميع المحادثات المؤرشفة",
"Export All Chats (All Users)": "تصدير جميع الدردشات (جميع المستخدمين)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "تصدير المحادثة (.json)",
"Export Chats": "تصدير جميع الدردشات",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "İxrac et",
"Export All Archived Chats": "Bütün arxivləşdirilmiş çatları ixrac et",
"Export All Chats (All Users)": "Bütün çatları ixrac et (Bütün istifadəçilər)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Çatı ixrac et (.json)",
"Export Chats": "Çatları İxrac Et",
"Export Config": "Konfiqurasiyanı İxrac Et",

View file

@ -846,6 +846,8 @@
"Export": "Износ",
"Export All Archived Chats": "Износ на всички архивирани чатове",
"Export All Chats (All Users)": "Експортване на всички чатове (за всички потребители)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Експортиране на чат (.json)",
"Export Chats": "Експортване на чатове",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "রপ্তানি",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "সব চ্যাট এক্সপোর্ট করুন (সব ইউজারের)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",
"Export Chats": "চ্যাটগুলো এক্সপোর্ট করুন",
"Export Config": "",

View file

@ -845,6 +845,8 @@
"Export": "ཕྱིར་གཏོང་།",
"Export All Archived Chats": "ཡིག་མཛོད་དུ་བཞག་པའི་ཁ་བརྡ་ཡོངས་རྫོགས་ཕྱིར་གཏོང་།",
"Export All Chats (All Users)": "ཁ་བརྡ་ཡོངས་རྫོགས་ཕྱིར་གཏོང་། (བེད་སྤྱོད་མཁན་ཡོངས་)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "ཁ་བརྡ་ཕྱིར་གཏོང་ (.json)",
"Export Chats": "ཁ་བརྡ་ཕྱིར་གཏོང་།",
"Export Config": "",

View file

@ -847,6 +847,8 @@
"Export": "Izvoz",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "Izvoz svih razgovora (svi korisnici)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Izvoz četa (.json)",
"Export Chats": "Izvoz razgovora",
"Export Config": "",

View file

@ -847,6 +847,8 @@
"Export": "Exportar",
"Export All Archived Chats": "Exportar tots els xats arxivats",
"Export All Chats (All Users)": "Exportar tots els xats (Tots els usuaris)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Exportar el xat (.json)",
"Export Chats": "Exportar els xats",
"Export Config": "Exportar la configuració",

View file

@ -846,6 +846,8 @@
"Export": "",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "I-export ang tanan nga mga chat (Tanan nga tiggamit)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",
"Export Chats": "I-export ang mga chat",
"Export Config": "",

View file

@ -848,6 +848,8 @@
"Export": "Exportovat",
"Export All Archived Chats": "Exportovat všechny archivované konverzace",
"Export All Chats (All Users)": "Exportovat všechny konverzace (všichni uživatelé)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Exportovat konverzaci (.json)",
"Export Chats": "Exportovat konverzace",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Eksportér",
"Export All Archived Chats": "Eksportér alle arkiverede chats",
"Export All Chats (All Users)": "Eksportér alle chats (alle brugere)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Eksportér chat (.json)",
"Export Chats": "Eksportér chats",
"Export Config": "",

View file

@ -182,7 +182,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "Sind Sie sicher, dass Sie alle Chats archivieren wollen? Dieser Vorgang kann nicht rückgängig gemacht werden.",
"Are you sure you want to clear all memories? This action cannot be undone.": "Sind Sie sicher, dass Sie alle Erinnerungen löschen möchten? Dieser Vorgang kann nicht rückgängig gemacht werden.",
"Are you sure you want to delete \"{{NAME}}\"?": "Sind Sie sicher, dass Sie \"{{NAME}}\" löschen möchten?",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete **{{modelName}}**?": "Sind Sie sicher, dass Sie **{{modelName}}** löschen möchten?",
"Are you sure you want to delete all chats? This action cannot be undone.": "Sind Sie sicher, dass Sie alle Chats löschen wollen? Dieser Vorgang kann nicht rückgängig gemacht werden.",
"Are you sure you want to delete this channel?": "Sind Sie sicher, dass Sie diesen Kanal löschen möchten?",
"Are you sure you want to delete this connection? This action cannot be undone.": "Möchten Sie diese Verbindung wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.",
@ -199,7 +199,7 @@
"Assistant": "Assistent",
"Async Embedding Processing": "Asynchrone Embedding-Verarbeitung",
"Attach File From Knowledge": "Datei aus Wissensspeicher anhängen",
"Attach Files": "",
"Attach Files": "Dateien anhängen",
"Attach Knowledge": "Wissen anhängen",
"Attach Notes": "Notizen anhängen",
"Attach Webpage": "Webseite anhängen",
@ -222,13 +222,13 @@
"AUTOMATIC1111 Base URL": "AUTOMATIC1111 Basis-URL",
"AUTOMATIC1111 Base URL is required.": "AUTOMATIC1111 Basis-URL ist erforderlich.",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "Automatische Injektion von System-Werkzeugen bei nativen Funktionsaufrufen (z. B. Zeitstempel, Erinnerungen, Chatverlauf, Notizen etc.)",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Automation": "Automatisierung",
"Automation created": "Automatisierung erstellt",
"Automation Name": "Name der Automatisierung",
"Automation title": "Titel der Automatisierung",
"Automation triggered": "Automatisierung ausgelöst",
"Automation updated": "Automatisierung aktualisiert",
"Automations": "Automatisierungen",
"Available list": "Verfügbare Liste",
"Available models": "Verfügbare Modelle",
"Available Tools": "Verfügbare Werkzeuge",
@ -259,7 +259,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "Bevorzugung oder Benachteiligung spezifischer Token für die Antwortsteuerung. Bias-Werte werden zwischen -100 und 100 (inklusive) begrenzt. (Standard: keine)",
"Brave": "Brave",
"Brave Search API Key": "Brave Search API-Schlüssel",
"Break down complex requests into trackable steps": "",
"Break down complex requests into trackable steps": "Zerlege komplexe Anfragen in einfache, nachverfolgbare Schritte",
"Browse and query knowledge bases": "Wissensbasen durchsuchen und abfragen",
"Builtin Tools": "Eingebaute Werkzeuge",
"Bullet List": "Aufzählungsliste",
@ -393,7 +393,7 @@
"Concurrent Requests": "Gleichzeitige Anfragen",
"Config": "Konfiguration",
"Config imported successfully": "Konfiguration erfolgreich importiert",
"Configuration": "",
"Configuration": "Konfiguration",
"Configure": "Konfigurieren",
"Confirm": "Bestätigen",
"Confirm Password": "Passwort bestätigen",
@ -463,7 +463,7 @@
"Create new secret key": "Neuen Geheimschlüssel erstellen",
"Create note": "Notiz erstellen",
"Create Note": "Notiz erstellen",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create scheduled prompts that run automatically on a recurring basis.": "Erstelle geplante Prompts, die automatisch in regelmäßigen Abständen ausgeführt werden.",
"Create your first note by clicking on the plus button below.": "Erstellen Sie Ihre erste Notiz durch Klick auf das Plus-Symbol unten.",
"Created at": "Erstellt am",
"Created At": "Erstellt am",
@ -485,7 +485,7 @@
"Data Controls": "Datenkontrolle",
"Database": "Datenbank",
"Datalab Marker API": "Datalab Marker API",
"Day": "",
"Day": "Tag",
"DD/MM/YYYY": "TT.MM.JJJJ",
"DDGS Backend": "DDGS Backend",
"December": "Dezember",
@ -516,7 +516,7 @@
"Delete All": "Alle löschen",
"Delete All Chats": "Alle Chats löschen",
"Delete all contents inside this folder": "Alle Inhalte in diesem Ordner löschen",
"Delete automation?": "",
"Delete automation?": "Automatisierung löschen?",
"Delete Chat": "Chat löschen",
"Delete chat?": "Chat löschen?",
"Delete File": "Datei löschen",
@ -661,7 +661,7 @@
"Embedding Concurrent Requests": "Gleichzeitige Embedding Anfragen",
"Embedding Model": "Embedding-Modell",
"Embedding Model Engine": "Embedding-Modell-Engine",
"Emojis": "",
"Emojis": "Emoji",
"Empty message": "Leere Nachricht",
"Enable All": "Alle aktivieren",
"Enable API Keys": "API-Schlüssel aktivieren",
@ -753,7 +753,7 @@
"Enter Perplexity Search API URL": "Perplexity Search API-URL eingeben",
"Enter Playwright Timeout": "Playwright-Timeout eingeben",
"Enter Playwright WebSocket URL": "Playwright WebSocket-URL eingeben",
"Enter prompt here.": "",
"Enter prompt here.": "Prompt hier eingeben.",
"Enter proxy URL (e.g. https://user:password@host:port)": "Proxy-URL eingeben (z. B. https://user:password@host:port)",
"Enter reasoning effort": "Reasoning Effort eingeben",
"Enter Score": "Wertung eingeben",
@ -778,7 +778,7 @@
"Enter system prompt here": "System-Prompt hier eingeben",
"Enter Tavily API Key": "Tavily API-Schlüssel eingeben",
"Enter Tavily Extract Depth": "Tavily Extraktionstiefe eingeben",
"Enter the prompt instructions for this automation...": "",
"Enter the prompt instructions for this automation...": "Gib die Prompt-Anweisungen für diese Automatisierung ein...",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "Öffentliche URL Ihrer WebUI eingeben. Diese wird für Links in Benachrichtigungen verwendet.",
"Enter the URL of the function to import": "URL der zu importierenden Funktion eingeben",
"Enter the URL to import": "URL zum Importieren eingeben",
@ -818,7 +818,7 @@
"Error accessing directory": "Fehler beim Zugriff auf das Verzeichnis",
"Error accessing Google Drive: {{error}}": "Fehler beim Zugriff auf Google Drive: {{error}}",
"Error accessing media devices.": "Fehler beim Zugriff auf Mediengeräte.",
"Error deleting model: {{error}}": "",
"Error deleting model: {{error}}": "Fehler beim Löschen des Modells: {{error}}",
"Error starting recording.": "Fehler beim Starten der Aufnahme.",
"Error unloading model: {{error}}": "Fehler beim Entladen des Modells: {{error}}",
"Error uploading file: {{error}}": "Fehler beim Hochladen der Datei: {{error}}",
@ -836,7 +836,7 @@
"Execute code": "Code ausführen",
"Execute code for analysis": "Code zur Analyse ausführen",
"Executing **{{NAME}}**...": "Führe **{{NAME}}** aus...",
"Execution Logs": "",
"Execution Logs": "Ausführungsprotokoll",
"Expand": "Ausklappen",
"Experimental": "Experimentell",
"Explain": "Erklären",
@ -846,6 +846,8 @@
"Export": "Exportieren",
"Export All Archived Chats": "Alle archivierten Chats exportieren",
"Export All Chats (All Users)": "Alle Chats exportieren (Alle Benutzer)",
"Export as CSV": "Als CSV exportieren",
"Export as JSON": "Als JSON exportieren",
"Export chat (.json)": "Chat exportieren (.json)",
"Export Chats": "Chats exportieren",
"Export Config": "Konfiguration exportieren",
@ -1100,7 +1102,7 @@
"Insert Suggestion Prompt to Input": "Vorschlags-Prompt in Eingabe einfügen",
"Install from Github URL": "Von GitHub-URL installieren",
"Instant Auto-Send After Voice Transcription": "Nach Sprachtranskription sofort senden",
"Instructions": "",
"Instructions": "Anweisungen",
"Integration": "Integration",
"Integrations": "Integrationen",
"Interface": "Benutzeroberfläche",
@ -1160,7 +1162,7 @@
"Last 90 days": "Letzte 90 Tage",
"Last Active": "Zuletzt aktiv",
"Last Modified": "Zuletzt geändert",
"Last ran": "",
"Last ran": "Letzter Durchlauf",
"Last reply": "Letzte Antwort",
"LDAP": "LDAP",
"LDAP server updated": "LDAP-Server aktualisiert",
@ -1267,7 +1269,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "Modell '{{modelName}}' wurde erfolgreich heruntergeladen.",
"Model '{{modelTag}}' is already in queue for downloading.": "Modell '{{modelTag}}' befindet sich bereits in der Warteschlange.",
"Model {{modelId}} not found": "Modell {{modelId}} nicht gefunden",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} deleted successfully": "Modell {{modelName}} erfolgreich gelöscht",
"Model {{modelName}} is not vision capable": "Das Modell {{modelName}} unterstützt keine Bilderkennung",
"Model {{name}} is now {{status}}": "Modell {{name}} ist jetzt {{status}}",
"Model {{name}} is now hidden": "Modell {{name}} ist jetzt ausgeblendet",
@ -1317,11 +1319,11 @@
"Name": "Name",
"Name and ID are required, please fill them out": "Name und ID sind erforderlich, bitte füllen Sie diese aus",
"Name your knowledge base": "Benennen Sie Ihren Wissensspeicher",
"Name, prompt, and model are required": "",
"Name, prompt, and model are required": "Name, Prompt und Modell sind erforderlich",
"Native": "Nativ",
"Never": "",
"Never": "Nie",
"New": "Neu",
"New Automation": "",
"New Automation": "Neue Automatisierung",
"New Button": "Neuer Button",
"New Chat": "Neuer Chat",
"New File": "Neue Datei",
@ -1340,11 +1342,11 @@
"New Webhook": "Neuer Webhook",
"new-channel": "neuer-kanal",
"Next message": "Nächste Nachricht",
"Next run": "",
"Next run": "Nächster Durchlauf",
"No access grants. Private to you.": "Keine Freigaben konfiguriert. Nur für Sie zugänglich.",
"No activity data": "Keine Aktivitätsdaten",
"No authentication": "Keine Authentifizierung",
"No automations found": "",
"No automations found": "Keine Automatisierungen gefunden",
"No chats found": "Keine Chats gefunden",
"No chats found for this user.": "Keine Chats für diesen Benutzer gefunden.",
"No chats found.": "Keine Chats gefunden.",
@ -1355,7 +1357,7 @@
"No data": "Keine Daten",
"No data found": "Keine Daten gefunden",
"No distance available": "Keine Distanz verfügbar",
"No execution logs available yet": "",
"No execution logs available yet": "Noch keine Ausführungsprotokolle vorhanden",
"No expiration can pose security risks.": "Ein fehlendes Ablaufdatum kann Sicherheitsrisiken bergen.",
"No feedback found": "Kein Feedback gefunden",
"No file selected": "Keine Datei ausgewählt",
@ -1402,7 +1404,7 @@
"Not factually correct": "Inhaltlich nicht korrekt",
"Not helpful": "Nicht hilfreich",
"Not Registered": "Nicht registriert",
"Not scheduled": "",
"Not scheduled": "Nicht geplant",
"Note": "Notiz",
"Note deleted successfully": "Notiz erfolgreich gelöscht",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Hinweis: Wenn Sie eine Mindestpunktzahl festlegen, liefert die Suche nur Dokumente zurück, deren Punktzahl größer oder gleich diesem Wert ist.",
@ -1476,7 +1478,7 @@
"or": "oder",
"Ordered List": "Nummerierte Liste",
"Other": "Andere",
"out of": "",
"out of": "von",
"Output": "Ausgabe",
"OUTPUT": "AUSGABE",
"Output format": "Ausgabeformat",
@ -1492,7 +1494,7 @@
"Password": "Passwort",
"Passwords do not match.": "Die Passwörter stimmen nicht überein.",
"Paste Large Text as File": "Großen Text als Datei einfügen",
"Paused": "",
"Paused": "Pausiert",
"PDF document (.pdf)": "PDF-Dokument (.pdf)",
"PDF Extract Images (OCR)": "Bilder aus PDFs extrahieren (OCR)",
"PDF Loader Mode": "PDF Loader Modus",
@ -1597,7 +1599,7 @@
"Reason": "Nachdenken",
"Reasoning Effort": "Reasoning Effort",
"Reasoning Tags": "Reasoning Tags",
"Recently Used": "",
"Recently Used": "Kürzlich verwendet",
"Record": "Aufnehmen",
"Record voice": "Stimme aufnehmen",
"Redirecting you to Open WebUI Community": "Sie werden zur Open WebUI Community weitergeleitet",
@ -1633,7 +1635,7 @@
"Renamed to {{name}}": "In {{name}} umbenannt",
"Render Markdown in Previews": "Markdown in der Vorschau rendern",
"Reorder Models": "Modelle neu anordnen",
"Repeats": "",
"Repeats": "Wiederholen",
"Reply": "Antworten",
"Reply in Thread": "Im Thread antworten",
"Reply to thread...": "Im Thread antworten...",
@ -1666,8 +1668,8 @@
"RTL": "RTL",
"Run": "Ausführen",
"Run All": "Alle starten",
"Run now": "",
"Run Now": "",
"Run now": "Jetzt ausführen",
"Run Now": "Jetzt ausführen",
"Running": "Läuft",
"Running...": "Läuft...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Führt Embedding-Aufgaben parallel aus, um die Verarbeitung zu beschleunigen. Deaktivieren Sie dies, falls Rate-Limits oder Ressourcenprobleme auftreten.",
@ -1678,15 +1680,15 @@
"Save Chat": "Chat speichern",
"Saved": "Gespeichert",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "Das direkte Speichern von Chat-Protokollen im Browserspeicher wird nicht mehr unterstützt. Bitte nehmen Sie sich einen Moment Zeit, um Ihre Chat-Protokolle herunterzuladen und zu löschen, indem Sie auf die Schaltfläche unten klicken. Sie können Ihre Chat-Protokolle später problemlos über das Backend wieder importieren.",
"Schedule": "",
"Scheduled time must be in the future": "",
"Schedule": "Planen",
"Scheduled time must be in the future": "Geplante Zeit muss in der Zukunft liegen",
"Scroll On Branch Change": "Bei Zweigwechsel scrollen",
"Search": "Suchen",
"Search a model": "Ein Modell suchen",
"Search all emojis": "Alle Emojis durchsuchen",
"Search and manage user memories": "Suche und manage Benutzer Erinnerungen",
"Search and view user chat history": "Suche und sehe die Chat History des Benutzers ein",
"Search Automations": "",
"Search Automations": "Automatisierungen suchen",
"Search Base": "Suchbasis",
"Search channels and channel messages": "Suche Kanäle und Kanalnachrichten",
"Search Chats": "Chats durchsuchen...",
@ -1756,7 +1758,7 @@
"Select how to split message text for TTS requests": "Wählen Sie, wie der Nachrichtentext für TTS-Anfragen aufgeteilt werden soll",
"Select Knowledge": "Wissensspeicher auswählen",
"Select Method": "Methode auswählen",
"Select model": "",
"Select model": "Modell auswählen",
"Select only one model to call": "Wählen Sie nur ein Modell zum Aufrufen aus",
"Select view": "Ansicht wählen",
"Selected model: {{modelName}}": "Ausgewähltes Modell: {{modelName}}",
@ -1866,7 +1868,7 @@
"Start of the channel": "Beginn des Kanals",
"Start Tag": "Start-Tag",
"Starting kernel...": "Kernel starten...",
"State": "",
"State": "Zustand",
"Status": "Status",
"Status cleared successfully": "Status erfolgreich gelöscht",
"Status updated successfully": "Status erfolgreich aktualisiert",
@ -1917,10 +1919,10 @@
"Talk to Model": "Mit dem Modell sprechen",
"Tap to interrupt": "Zum Unterbrechen tippen",
"Task List": "Aufgabenliste",
"Task Management": "",
"Task Management": "Aufgabenverwaltung",
"Task Model": "Aufgabenmodell",
"Tasks": "Aufgaben",
"tasks completed": "",
"tasks completed": "Aufgaben abgeschlossen",
"Tavily API Key": "Tavily-API-Schlüssel",
"Tavily Extract Depth": "Tavily Extraktionstiefe",
"Tell us more:": "Erzählen Sie uns mehr:",
@ -1987,7 +1989,7 @@
"Tika": "Tika",
"Tika Server URL required.": "Tika-Server-URL erforderlich.",
"Tiktoken": "Tiktoken",
"Time": "",
"Time": "Zeit",
"Time & Calculation": "Zeit & Berechnung",
"Timeout": "Timeout",
"Title": "Titel",
@ -2005,7 +2007,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "Um Toolkits auszuwählen, fügen Sie sie zuerst dem Arbeitsbereich \"Werkzeuge\" hinzu.",
"Toast notifications for new updates": "Toast-Benachrichtigungen für neue Updates",
"Today": "Heute",
"Today at": "",
"Today at": "Heute um",
"Today at {{LOCALIZED_TIME}}": "Heute um {{LOCALIZED_TIME}}",
"Toggle {{COUNT}} sources": "{{COUNT}} Quellen umschalten",
"Toggle 1 source": "Eine Quelle umschalten",
@ -2144,7 +2146,7 @@
"Waiting for upload...": "Warte auf Datei upload...",
"Warning": "Warnung",
"Warning:": "Warnung:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "Warnung: Wenn Sie dies aktivieren, können Nutzer geplante Prompts automatisch ausführen.",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "Warnung: Wenn Sie dies aktivieren, können Benutzer beliebigen Code auf den Server hochladen.",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "Warnung: Die Jupyter-Ausführung ermöglicht beliebige Codeausführung und birgt erhebliche Sicherheitsrisiken – gehen Sie mit äußerster Vorsicht vor.",
"Web": "Web",
@ -2179,7 +2181,7 @@
"Width": "Breite",
"Wikipedia": "Wikipedia",
"Won": "Gewonnen",
"Working Directory": "",
"Working Directory": "Arbeitsverzeichnis",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "Funktioniert zusammen mit Top-K. Ein höherer Wert (z. B. 0.95) führt zu vielfältigerem Text, während ein niedrigerer Wert (z. B. 0.5) fokussierteren und konservativeren Text erzeugt.",
"Workspace": "Arbeitsbereich",
"Workspace Permissions": "Arbeitsbereichsberechtigungen",

View file

@ -846,6 +846,8 @@
"Export": "",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "Export All Chats (All Doggos)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",
"Export Chats": "Export Barks",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Εξαγωγή",
"Export All Archived Chats": "Εξαγωγή Όλων των Αρχειοθετημένων Συνομιλιών",
"Export All Chats (All Users)": "Εξαγωγή Όλων των Συνομιλιών (Όλοι οι Χρήστες)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Εξαγωγή συνομιλίας (.json)",
"Export Chats": "Εξαγωγή Συνομιλιών",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",
"Export Chats": "",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",
"Export Chats": "",
"Export Config": "",

File diff suppressed because it is too large Load diff

View file

@ -846,6 +846,8 @@
"Export": "Ekspordi",
"Export All Archived Chats": "Ekspordi kõik arhiveeritud vestlused",
"Export All Chats (All Users)": "Ekspordi kõik vestlused (kõik kasutajad)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Ekspordi vestlus (.json)",
"Export Chats": "Ekspordi vestlused",
"Export Config": "Ekspordi seadistus",

View file

@ -846,6 +846,8 @@
"Export": "Esportatu",
"Export All Archived Chats": "Esportatu Artxibatutako Txat Guztiak",
"Export All Chats (All Users)": "Esportatu Txat Guztiak (Erabiltzaile Guztiak)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Esportatu txata (.json)",
"Export Chats": "Esportatu Txatak",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "خروجی گرفتن",
"Export All Archived Chats": "خروجی گرفتن تمام گفتگوهای بایگانی شده",
"Export All Chats (All Users)": "خروجی گرفتن همه گفتگو\u200cها (همه کاربران)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "خروجی گرفتن گفتگو (json)",
"Export Chats": "خروجی گرفتن گفتگوها",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Vie",
"Export All Archived Chats": "Vie kaikki arkistoidut keskustelut",
"Export All Chats (All Users)": "Vie kaikki keskustelut (kaikki käyttäjät)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Vie keskustelu (.json)",
"Export Chats": "Vie keskustelut",
"Export Config": "Vie asetukset",

View file

@ -847,6 +847,8 @@
"Export": "Exportation",
"Export All Archived Chats": "Exporter toutes les conversations archivées",
"Export All Chats (All Users)": "Exporter toutes les conversations (de tous les utilisateurs)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Exporter la conversation (.json)",
"Export Chats": "Exporter les conversations",
"Export Config": "",

View file

@ -847,6 +847,8 @@
"Export": "Exporter",
"Export All Archived Chats": "Exporter toutes les conversations archivées",
"Export All Chats (All Users)": "Exporter toutes les conversations (de tous les utilisateurs)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Exporter la conversation (.json)",
"Export Chats": "Exporter les conversations",
"Export Config": "Exporter la configuration",

View file

@ -846,6 +846,8 @@
"Export": "Exportar",
"Export All Archived Chats": "Exportar todos os chats Arquivados",
"Export All Chats (All Users)": "Exportar todos os chats (Todos os usuarios)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Exportar chat (.json)",
"Export Chats": "Exportar Chats",
"Export Config": "",

View file

@ -847,6 +847,8 @@
"Export": "ייצא",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "ייצוא כל הצ'אטים (כל המשתמשים)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",
"Export Chats": "ייצוא צ'אטים",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "निर्यातित माल",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "सभी चैट निर्यात करें (सभी उपयोगकर्ताओं की)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",
"Export Chats": "चैट निर्यात करें",
"Export Config": "",

View file

@ -847,6 +847,8 @@
"Export": "Izvoz",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "Izvoz svih razgovora (svi korisnici)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Izvoz četa (.json)",
"Export Chats": "Izvoz razgovora",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Exportálás",
"Export All Archived Chats": "Minden archivált csevegés exportálása",
"Export All Chats (All Users)": "Minden beszélgetés exportálása (minden felhasználó)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Beszélgetés exportálása (.json)",
"Export Chats": "Beszélgetések exportálása",
"Export Config": "",

View file

@ -845,6 +845,8 @@
"Export": "Ekspor",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "Ekspor Semua Obrolan (Semua Pengguna)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Ekspor obrolan (.json)",
"Export Chats": "Ekspor Obrolan",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Easpórtáil",
"Export All Archived Chats": "Easpórtáil Gach Comhrá Cartlainne",
"Export All Chats (All Users)": "Easpórtáil gach comhrá (Gach Úsáideoir)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Easpórtáil comhrá (.json)",
"Export Chats": "Comhráite Easpórtá",
"Export Config": "Cumraíocht Easpórtála",

View file

@ -847,6 +847,8 @@
"Export": "Esportazione",
"Export All Archived Chats": "Esporta Tutte le Chat Archiviate",
"Export All Chats (All Users)": "Esporta Tutte le Chat (Tutti gli Utenti)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Esporta chat (.json)",
"Export Chats": "Esporta Chat",
"Export Config": "",

View file

@ -845,6 +845,8 @@
"Export": "エクスポート",
"Export All Archived Chats": "すべてのアーカイブチャットをエクスポート",
"Export All Chats (All Users)": "すべてのチャットをエクスポート (すべてのユーザー)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "チャットをエクスポート(.json)",
"Export Chats": "チャットをエクスポート",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "გატანა",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "ყველა ჩატის გატანა (ყველა მომხმარებელი)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",
"Export Chats": "ჩატების გატანა",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Asifeḍ",
"Export All Archived Chats": "Kter akk Archived Chats",
"Export All Chats (All Users)": "Kter akk idiwenniyen (n yiseqdacen akk)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Kter asqerdec (.json)",
"Export Chats": "Sifeḍ idiwenniyen",
"Export Config": "",

View file

@ -845,6 +845,8 @@
"Export": "내보내기",
"Export All Archived Chats": "모든 보관된 채팅 내보내기",
"Export All Chats (All Users)": "모든 채팅 내보내기(모든 사용자)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "채팅 내보내기 (.json)",
"Export Chats": "채팅 내보내기",
"Export Config": "",

View file

@ -848,6 +848,8 @@
"Export": "Eksportuoti",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "Eksportuoti visų naudotojų visus pokalbius",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Eksportuoti pokalbį (.json)",
"Export Chats": "Eksportuoti pokalbius",
"Export Config": "",

View file

@ -847,6 +847,8 @@
"Export": "Eksportēt",
"Export All Archived Chats": "Eksportēt visas arhivētās tērzēšanas",
"Export All Chats (All Users)": "Eksportēt visas tērzēšanas (visi lietotāji)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Eksportēt tērzēšanu (.json)",
"Export Chats": "Eksportēt tērzēšanas",
"Export Config": "",

View file

@ -845,6 +845,8 @@
"Export": "Eksport",
"Export All Archived Chats": "Eksport Semua Sembang Tersimpan",
"Export All Chats (All Users)": "Eksport Semua Perbualan (Semua Pengguna)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Eksport perbualan (.json)",
"Export Chats": "Eksport Perbualan",
"Export Config": "Eksport Konfigurasi",

View file

@ -846,6 +846,8 @@
"Export": "Eksporter",
"Export All Archived Chats": "Eksporter alle arkiverte chatter",
"Export All Chats (All Users)": "Eksporter alle chatter (alle brukere)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Eksporter chat (.json)",
"Export Chats": "Eksporter chatter",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Exporteren",
"Export All Archived Chats": "Exporteer alle gearchiveerde chats",
"Export All Chats (All Users)": "Exporteer alle chats (Alle gebruikers)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Exporteer chat (.json)",
"Export Chats": "Exporteer chats",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "ਨਿਰਯਾਤ",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "ਸਾਰੀਆਂ ਗੱਲਾਂ ਨਿਰਯਾਤ ਕਰੋ (ਸਾਰੇ ਉਪਭੋਗਤਾ)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",
"Export Chats": "ਗੱਲਾਂ ਨਿਰਯਾਤ ਕਰੋ",
"Export Config": "",

View file

@ -848,6 +848,8 @@
"Export": "Eksportuj",
"Export All Archived Chats": "Eksportuj wszystkie zarchiwizowane czaty",
"Export All Chats (All Users)": "Eksportuj wszystkie czaty (Wszyscy użytkownicy)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Eksportuj czat (.json)",
"Export Chats": "Eksportuj czaty",
"Export Config": "",

View file

@ -847,6 +847,8 @@
"Export": "Exportar",
"Export All Archived Chats": "Exportar todos os chats arquivados",
"Export All Chats (All Users)": "Exportar Todos os Chats (Todos os Usuários)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Exportar chat (.json)",
"Export Chats": "Exportar Chats",
"Export Config": "Exportar Configuração",

View file

@ -847,6 +847,8 @@
"Export": "Exportar",
"Export All Archived Chats": "Exportar Todas as Conversas Arquivadas",
"Export All Chats (All Users)": "Exportar Todas as Conversas (Todos os Utilizadores)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Exportar Conversa (.json)",
"Export Chats": "Exportar Conversas",
"Export Config": "Exportar Configuração",

View file

@ -847,6 +847,8 @@
"Export": "Exportă",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "Exportă Toate Conversațiile (Toți Utilizatorii)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Exportă conversația (.json)",
"Export Chats": "Exportă Conversațiile",
"Export Config": "",

View file

@ -848,6 +848,8 @@
"Export": "Экспорт",
"Export All Archived Chats": "Экспортировать ВСЕ Архивированные Чаты",
"Export All Chats (All Users)": "Экспортировать все чаты (всех пользователей)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Экспортировать чат (.json)",
"Export Chats": "Экспортировать чаты",
"Export Config": "Экспортировать конфигурации",

View file

@ -848,6 +848,8 @@
"Export": "Exportovať",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "Exportovať všetky konverzácie (všetci užívatelia)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Exportovať konverzáciu (.json)",
"Export Chats": "Exportovať konverzácie",
"Export Config": "",

View file

@ -847,6 +847,8 @@
"Export": "Извоз",
"Export All Archived Chats": "Извези све архиве",
"Export All Chats (All Users)": "Извези сва ћаскања (сви корисници)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Извези ћаскање (.json)",
"Export Chats": "Извези ћаскања",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Export",
"Export All Archived Chats": "Exportera alla arkiverade chattar",
"Export All Chats (All Users)": "Exportera alla chattar (alla användare)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Exportera chatt (.json)",
"Export Chats": "Exportera chattar",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "ஏற்றுமதி",
"Export All Archived Chats": "அனைத்து காப்பகப்படுத்தப்பட்ட அரட்டைகளையும் ஏற்றுமதி செய்யவும்",
"Export All Chats (All Users)": "அனைத்து அரட்டைகளையும் ஏற்றுமதி செய் (அனைத்து பயனர்களும்)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "ஏற்றுமதி அரட்டை (.json)",
"Export Chats": "அரட்டைகளை ஏற்றுமதி செய்யவும்",
"Export Config": "ஏற்றுமதி கட்டமைப்பு",

View file

@ -845,6 +845,8 @@
"Export": "ส่งออก",
"Export All Archived Chats": "ส่งออกแชทที่เก็บถาวรทั้งหมด",
"Export All Chats (All Users)": "ส่งออกการแชททั้งหมด (ผู้ใช้ทั้งหมด)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "ส่งออกการสนทนา (.json)",
"Export Chats": "ส่งออกการสนทนา",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Eksport",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",
"Export Chats": "",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Dışa Aktar",
"Export All Archived Chats": "Tüm Arşivlenmiş Sohbetleri Dışa Aktar",
"Export All Chats (All Users)": "Tüm Sohbetleri Dışa Aktar (Tüm Kullanıcılar)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Sohbeti dışa aktar (.json)",
"Export Chats": "Sohbetleri Dışa Aktar",
"Export Config": "Yapılandırmayı Dışa Aktar",

View file

@ -846,6 +846,8 @@
"Export": "چىقىرىش",
"Export All Archived Chats": "بارلىق ئارخىپلانغان سۆھبەتلەرنى چىقىرىش",
"Export All Chats (All Users)": "بارلىق سۆھبەتلەرنى چىقىرىش (بارلىق ئىشلەتكۈچىلەر)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "سۆھبەت چىقىرىش (.json)",
"Export Chats": "سۆھبەتلەرنى چىقىرىش",
"Export Config": "",

View file

@ -848,6 +848,8 @@
"Export": "Експорт",
"Export All Archived Chats": "Експорт всіх архівованих чатів",
"Export All Chats (All Users)": "Експорт всіх чатів (всіх користувачів)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Експорт чату (.json)",
"Export Chats": "Експорт чатів",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "برآمد کریں",
"Export All Archived Chats": "",
"Export All Chats (All Users)": "تمام چیٹس برآمد کریں (تمام صارفین)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "چیٹ برآمد کریں (.json)",
"Export Chats": "چیٹس برآمد کریں",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Экспорт",
"Export All Archived Chats": "Барча архивланган суҳбатларни экспорт қилиш",
"Export All Chats (All Users)": "Барча суҳбатларни экспорт қилиш (барча фойдаланувчилар)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Чатни экспорт қилиш (.жсон)",
"Export Chats": "Чатларни экспорт қилиш",
"Export Config": "",

View file

@ -846,6 +846,8 @@
"Export": "Eksport",
"Export All Archived Chats": "Barcha arxivlangan suhbatlarni eksport qilish",
"Export All Chats (All Users)": "Barcha suhbatlarni eksport qilish (barcha foydalanuvchilar)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Chatni eksport qilish (.json)",
"Export Chats": "Chatlarni eksport qilish",
"Export Config": "",

View file

@ -845,6 +845,8 @@
"Export": "Xuất khẩu",
"Export All Archived Chats": "Xuất Tất cả Chat Đã Lưu trữ",
"Export All Chats (All Users)": "Tải về tất cả nội dung chat (tất cả mọi người)",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "Tải chat (.json)",
"Export Chats": "Tải nội dung chat về máy",
"Export Config": "",