mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-08 03:08:02 +00:00
Merge branch 'dev' into fix/profile-image-url-validation
This commit is contained in:
commit
9af9635751
87 changed files with 1266 additions and 677 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
####################################
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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={})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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') => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@
|
|||
let contentContainerElement;
|
||||
let floatingButtonsElement;
|
||||
|
||||
|
||||
let sourceIds = [];
|
||||
$: getSourceIds(sources);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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ó",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "Экспортировать конфигурации",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "ஏற்றுமதி கட்டமைப்பு",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue