Merge upstream/dev into sync

Resolve backend/open_webui/routers/files.py conflict in
process_uploaded_file: take upstream's async get_async_db_context()
session, which pairs with the await _process_handler(db_session) this
branch already added. The old sync 'with SessionLocal() as db_session'
block wouldn't compile against the async handler and would have
reintroduced the "coroutine created but never awaited" bug this branch
just fixed.
This commit is contained in:
DrMelone 2026-04-13 01:01:19 +02:00
commit a2238f17b6
32 changed files with 601 additions and 283 deletions

View file

@ -806,6 +806,36 @@ else:
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT
####################################
# AIOHTTP Connection Pool
####################################
AIOHTTP_POOL_CONNECTIONS = os.environ.get('AIOHTTP_POOL_CONNECTIONS', '')
if AIOHTTP_POOL_CONNECTIONS == '':
AIOHTTP_POOL_CONNECTIONS = None
else:
try:
AIOHTTP_POOL_CONNECTIONS = int(AIOHTTP_POOL_CONNECTIONS)
except ValueError:
AIOHTTP_POOL_CONNECTIONS = None
AIOHTTP_POOL_CONNECTIONS_PER_HOST = os.environ.get('AIOHTTP_POOL_CONNECTIONS_PER_HOST', '')
if AIOHTTP_POOL_CONNECTIONS_PER_HOST == '':
AIOHTTP_POOL_CONNECTIONS_PER_HOST = None
else:
try:
AIOHTTP_POOL_CONNECTIONS_PER_HOST = int(AIOHTTP_POOL_CONNECTIONS_PER_HOST)
except ValueError:
AIOHTTP_POOL_CONNECTIONS_PER_HOST = None
AIOHTTP_POOL_DNS_TTL = os.environ.get('AIOHTTP_POOL_DNS_TTL', '300')
try:
AIOHTTP_POOL_DNS_TTL = int(AIOHTTP_POOL_DNS_TTL)
if AIOHTTP_POOL_DNS_TTL < 0:
AIOHTTP_POOL_DNS_TTL = 300
except ValueError:
AIOHTTP_POOL_DNS_TTL = 300
RAG_EMBEDDING_TIMEOUT = os.environ.get('RAG_EMBEDDING_TIMEOUT', '')
if RAG_EMBEDDING_TIMEOUT == '':
@ -909,6 +939,9 @@ AUDIT_INCLUDED_PATHS = os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')
AUDIT_INCLUDED_PATHS = [path.strip() for path in AUDIT_INCLUDED_PATHS]
AUDIT_INCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_INCLUDED_PATHS if path]
# When enabled, GET requests are also audited (disabled by default to avoid log noise)
ENABLE_AUDIT_GET_REQUESTS = os.getenv('ENABLE_AUDIT_GET_REQUESTS', 'False').lower() == 'true'
####################################
# OPENTELEMETRY

View file

@ -67,6 +67,7 @@ from open_webui.socket.main import (
periodic_session_pool_cleanup,
get_event_emitter,
get_models_in_use,
get_user_id_from_session_pool,
)
from open_webui.routers import (
analytics,
@ -475,6 +476,7 @@ from open_webui.env import (
LICENSE_KEY,
AUDIT_EXCLUDED_PATHS,
AUDIT_INCLUDED_PATHS,
ENABLE_AUDIT_GET_REQUESTS,
AUDIT_LOG_LEVEL,
CHANGELOG,
REDIS_URL,
@ -565,6 +567,7 @@ from open_webui.tasks import (
list_task_ids_by_item_id,
create_task,
stop_task,
stop_item_tasks,
list_tasks,
) # Import from tasks.py
@ -718,6 +721,10 @@ async def lifespan(app: FastAPI):
yield
# Shutdown: clean up shared resources
from open_webui.utils.session_pool import close_session
await close_session()
if hasattr(app.state, 'redis_task_command_listener'):
app.state.redis_task_command_listener.cancel()
@ -1390,50 +1397,6 @@ app.add_middleware(RedirectMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
class APIKeyRestrictionMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope['type'] == 'http':
request = Request(scope)
auth_header = request.headers.get('Authorization')
token = None
if auth_header:
parts = auth_header.split(' ', 1)
if len(parts) == 2:
token = parts[1]
# Only apply restrictions if an sk- API key is used
if token and token.startswith('sk-'):
# Check if restrictions are enabled
if app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
allowed_paths = [
path.strip()
for path in str(app.state.config.API_KEYS_ALLOWED_ENDPOINTS).split(',')
if path.strip()
]
request_path = request.url.path
# Match exact path or prefix path
is_allowed = any(
request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths
)
if not is_allowed:
await JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={'detail': 'API key not allowed to access this endpoint.'},
)(scope, receive, send)
return
await self.app(scope, receive, send)
app.add_middleware(APIKeyRestrictionMiddleware)
@app.middleware('http')
async def commit_session_after_request(request: Request, call_next):
@ -1560,6 +1523,7 @@ if audit_level != AuditLevel.NONE:
audit_level=audit_level,
excluded_paths=AUDIT_EXCLUDED_PATHS,
included_paths=AUDIT_INCLUDED_PATHS,
audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
max_body_size=MAX_BODY_LOG_SIZE,
)
##################################
@ -2012,7 +1976,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user=De
@app.post('/api/tasks/stop/{task_id}')
async def stop_task_endpoint(request: Request, task_id: str, user=Depends(get_verified_user)):
async def stop_task_endpoint(request: Request, task_id: str, user=Depends(get_admin_user)):
try:
result = await stop_task(request.app.state.redis, task_id)
return result
@ -2021,15 +1985,21 @@ async def stop_task_endpoint(request: Request, task_id: str, user=Depends(get_ve
@app.get('/api/tasks')
async def list_tasks_endpoint(request: Request, user=Depends(get_verified_user)):
async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)):
return {'tasks': await list_tasks(request.app.state.redis)}
@app.get('/api/tasks/chat/{chat_id}')
@app.get('/api/tasks/chat/{chat_id:path}')
async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
chat = await Chats.get_chat_by_id(chat_id)
if chat is None or chat.user_id != user.id:
return {'task_ids': []}
if chat_id.startswith('local:'):
socket_id = chat_id[len('local:'):]
owner_id = get_user_id_from_session_pool(socket_id)
if owner_id != user.id and user.role != 'admin':
return {'task_ids': []}
else:
chat = await Chats.get_chat_by_id(chat_id)
if chat is None or (chat.user_id != user.id and user.role != 'admin'):
return {'task_ids': []}
task_ids = await list_task_ids_by_item_id(request.app.state.redis, chat_id)
@ -2037,6 +2007,21 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
return {'task_ids': task_ids}
@app.post('/api/tasks/chat/{chat_id:path}/stop')
async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
if chat_id.startswith('local:'):
socket_id = chat_id[len('local:'):]
owner_id = get_user_id_from_session_pool(socket_id)
if owner_id != user.id and user.role != 'admin':
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
else:
chat = await Chats.get_chat_by_id(chat_id)
if chat is None or (chat.user_id != user.id and user.role != 'admin'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
result = await stop_item_tasks(request.app.state.redis, chat_id)
return result
##################################
#
# Config Endpoints

View file

@ -1,4 +1,5 @@
import asyncio
import ipaddress
import logging
import socket
import ssl
@ -84,11 +85,9 @@ def validate_url(url: Union[str, Sequence[str]]):
ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname)
# Check if any of the resolved addresses are private
# This is technically still vulnerable to DNS rebinding attacks, as we don't control WebBaseLoader
for ip in ipv4_addresses:
if validators.ipv4(ip, private=True):
raise ValueError(ERROR_MESSAGES.INVALID_URL)
for ip in ipv6_addresses:
if validators.ipv6(ip, private=True):
for ip in ipv4_addresses + ipv6_addresses:
addr = ipaddress.ip_address(ip)
if not addr.is_global:
raise ValueError(ERROR_MESSAGES.INVALID_URL)
return True
elif isinstance(url, Sequence):

View file

@ -54,6 +54,7 @@ from open_webui.config import (
OAUTH_PROVIDERS,
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
)
from open_webui.utils.oauth import auth_manager_config
from pydantic import BaseModel
from open_webui.utils.misc import parse_duration, validate_email_format
@ -322,6 +323,14 @@ async def ldap_auth(
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
# Reject empty passwords before attempting the LDAP bind.
# Per RFC 4513 §5.1.2, a Simple Bind with a non-empty DN but empty
# password is "unauthenticated simple authentication" — many LDAP
# servers (OpenLDAP default, some AD configs) return success for these,
# which would grant access without valid credentials.
if not form_data.password or not form_data.password.strip():
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# NOW load LDAP config variables
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
@ -1295,6 +1304,17 @@ async def token_exchange(
)
email = email.lower()
# Enforce domain allowlist — same check as the normal OAuth callback
if (
'*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
):
log.warning(f'Token exchange denied: email domain not in allowed domains list')
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Try to find the user by OAuth sub
user = await Users.get_user_by_oauth_sub(provider, sub, db=db)

View file

@ -167,15 +167,6 @@ async def create_new_automation(
await check_automation_limits(request, user, form_data.data.rrule, db, is_create=True)
# Validate terminal server exists if linked
if form_data.data.terminal and form_data.data.terminal.server_id:
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
if not any(c.get('id') == form_data.data.terminal.server_id for c in connections):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Terminal server not found',
)
tz = user.timezone
automation = await Automations.insert(user.id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db)
return await enrich_automation(automation, db, tz=tz)
@ -226,15 +217,6 @@ async def update_automation_by_id(
await check_automation_limits(request, user, form_data.data.rrule, db, is_create=False)
# Validate terminal server exists if linked
if form_data.data.terminal and form_data.data.terminal.server_id:
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
if not any(c.get('id') == form_data.data.terminal.server_id for c in connections):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Terminal server not found',
)
tz = user.timezone
updated = await Automations.update_by_id(id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db)
return await enrich_automation(updated, db, tz=tz)

View file

@ -61,7 +61,7 @@ from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.utils.webhook import post_webhook
from open_webui.utils.channels import extract_mentions, replace_mentions
from open_webui.internal.db import get_async_session
@ -303,6 +303,14 @@ async def create_new_channel(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_channels',
)
try:
if form_data.type == 'dm':
existing_channel = await Channels.get_dm_channel_by_user_ids([user.id, *form_data.user_ids], db=db)
@ -633,6 +641,14 @@ async def update_channel_by_id(
if channel.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_channels',
)
try:
channel = await Channels.update_channel_by_id(id, form_data, db=db)
return ChannelModel(**channel.model_dump())

View file

@ -23,7 +23,7 @@ from fastapi.params import Form as FormParam
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import get_async_session, SessionLocal
from open_webui.internal.db import get_async_session, get_async_db_context
from open_webui.constants import ERROR_MESSAGES
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
@ -155,7 +155,7 @@ async def process_uploaded_file(
if db:
await _process_handler(db)
else:
with SessionLocal() as db_session:
async with get_async_db_context() as db_session:
await _process_handler(db_session)
@ -555,7 +555,7 @@ async def update_file_data_content_by_id(
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'write', user, db=db):
try:
process_file(
await process_file(
request,
ProcessFileForm(file_id=id, content=form_data.content),
user=user,
@ -575,7 +575,7 @@ async def update_file_data_content_by_id(
# Remove old embeddings for this file from the KB collection
VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id})
# Re-add from the now-updated file-{file_id} collection
process_file(
await process_file(
request,
ProcessFileForm(file_id=id, collection_name=knowledge.id),
user=user,

View file

@ -832,7 +832,7 @@ async def image_edits(
except Exception as e:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
async def get_image_file_item(base64_string, param_name='image'):
def get_image_file_item(base64_string, param_name='image'):
data = base64_string
header, encoded = data.split(',', 1)
mime_type = header.split(';')[0].lstrip('data:')

View file

@ -2,7 +2,7 @@ from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query, UploadFile, File, Form
from fastapi.responses import StreamingResponse
from fastapi.concurrency import run_in_threadpool
import logging
import io
import zipfile
@ -320,8 +320,7 @@ async def reindex_knowledge_files(
failed_files = []
for file in files:
try:
await run_in_threadpool(
process_file,
await process_file(
request,
ProcessFileForm(file_id=file.id, collection_name=knowledge_base.id),
user=user,
@ -544,7 +543,7 @@ async def update_knowledge_access_by_id(
await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db)
return KnowledgeFilesResponse(
**await Knowledges.get_knowledge_by_id(id=id, db=db).model_dump(),
**(await Knowledges.get_knowledge_by_id(id=id, db=db)).model_dump(),
files=await Knowledges.get_file_metadatas_by_id(id, db=db),
)
@ -660,7 +659,7 @@ async def add_file_to_knowledge_by_id(
# Add content to the vector database
try:
process_file(
await process_file(
request,
ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user,
@ -947,7 +946,7 @@ async def update_file_from_knowledge_by_id(
# Add content to the vector database
try:
process_file(
await process_file(
request,
ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user,
@ -1196,7 +1195,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user), db: As
log.debug(e)
pass
knowledge = Knowledges.reset_knowledge_by_id(id=id, db=db)
knowledge = await Knowledges.reset_knowledge_by_id(id=id, db=db)
return knowledge

View file

@ -50,7 +50,10 @@ from open_webui.models.groups import Groups
from open_webui.utils.access_control import check_model_access
from open_webui.utils.misc import (
calculate_sha256,
)
from open_webui.utils.session_pool import (
cleanup_response,
get_session,
stream_wrapper,
)
from open_webui.utils.payload import (
@ -122,10 +125,7 @@ async def send_request(
r = None
streaming = False
try:
session = aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
session = await get_session()
headers = {
'Content-Type': 'application/json',
@ -140,6 +140,7 @@ async def send_request(
r = await session.request(
method, url, data=payload, headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
if not r.ok:
@ -165,7 +166,7 @@ async def send_request(
streaming = True
return StreamingResponse(
stream_wrapper(r, session),
stream_wrapper(r),
status_code=r.status,
headers=response_headers,
)
@ -184,7 +185,7 @@ async def send_request(
)
finally:
if not streaming:
await cleanup_response(r, session)
await cleanup_response(r)
def get_api_key(idx, url, configs):

View file

@ -52,9 +52,12 @@ from open_webui.utils.payload import (
apply_system_prompt_to_body,
)
from open_webui.utils.misc import (
cleanup_response,
convert_logit_bias_input_to_json,
stream_chunks_handler,
)
from open_webui.utils.session_pool import (
cleanup_response,
get_session,
stream_wrapper,
)
@ -1174,12 +1177,11 @@ async def generate_chat_completion(
payload = json.dumps(payload)
r = None
session = None
streaming = False
response = None
try:
session = aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT))
session = await get_session()
r = await session.request(
method='POST',
@ -1188,13 +1190,14 @@ async def generate_chat_completion(
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
# Check if response is SSE
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(r, session, stream_chunks_handler),
stream_wrapper(r, content_handler=stream_chunks_handler),
status_code=r.status,
headers=dict(r.headers),
)
@ -1225,7 +1228,7 @@ async def generate_chat_completion(
)
finally:
if not streaming:
await cleanup_response(r, session)
await cleanup_response(r)
async def embeddings(request: Request, form_data: dict, user):
@ -1261,27 +1264,24 @@ async def embeddings(request: Request, form_data: dict, user):
)
r = None
session = None
streaming = False
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
try:
session = aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
session = await get_session()
r = await session.request(
method='POST',
url=f'{url}/embeddings',
data=body,
headers=headers,
cookies=cookies,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(r, session),
stream_wrapper(r),
status_code=r.status,
headers=dict(r.headers),
)
@ -1306,7 +1306,7 @@ async def embeddings(request: Request, form_data: dict, user):
)
finally:
if not streaming:
await cleanup_response(r, session)
await cleanup_response(r)
class ResponsesForm(BaseModel):
@ -1365,7 +1365,6 @@ async def responses(
)
r = None
session = None
streaming = False
try:
@ -1388,10 +1387,7 @@ async def responses(
else:
request_url = f'{url}/responses'
session = aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
session = await get_session()
r = await session.request(
method='POST',
url=request_url,
@ -1399,13 +1395,14 @@ async def responses(
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
# Check if response is SSE
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(r, session),
stream_wrapper(r),
status_code=r.status,
headers=dict(r.headers),
)
@ -1433,7 +1430,7 @@ async def responses(
)
finally:
if not streaming:
await cleanup_response(r, session)
await cleanup_response(r)
@router.api_route('/{path:path}', methods=['GET', 'POST', 'PUT', 'DELETE'])
@ -1479,7 +1476,6 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
)
r = None
session = None
streaming = False
try:
@ -1508,10 +1504,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
else:
request_url = f'{url}/{path}'
session = aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
session = await get_session()
r = await session.request(
method=request.method,
url=request_url,
@ -1519,13 +1512,14 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
# Check if response is SSE
if 'text/event-stream' in r.headers.get('Content-Type', ''):
streaming = True
return StreamingResponse(
stream_wrapper(r, session),
stream_wrapper(r),
status_code=r.status,
headers=dict(r.headers),
)
@ -1553,4 +1547,4 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
)
finally:
if not streaming:
await cleanup_response(r, session)
await cleanup_response(r)

View file

@ -40,6 +40,7 @@ from open_webui.utils.auth import (
validate_password,
)
from open_webui.utils.access_control import get_permissions, has_permission
from open_webui.socket.main import disconnect_user_sessions
log = logging.getLogger(__name__)
@ -620,6 +621,10 @@ async def update_user_by_id(
)
if updated_user:
# If the role changed, disconnect all socket sessions so stale
# privileges cached in SESSION_POOL are invalidated.
if updated_user.role != user.role:
await disconnect_user_sessions(user_id)
return updated_user
raise HTTPException(
@ -659,6 +664,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user), db: Asyn
result = await Auths.delete_auth_by_id(user_id, db=db)
if result:
await disconnect_user_sessions(user_id)
return True
raise HTTPException(

View file

@ -312,6 +312,24 @@ async def enter_room_for_users(room: str, user_ids: list[str]):
log.debug(f'Failed to make users {user_ids} join room {room}: {e}')
async def disconnect_user_sessions(user_id: str):
"""Disconnect all Socket.IO sessions belonging to a user.
Call this when a user's role is changed or the user is deleted so that
stale role/permission data cached in SESSION_POOL is invalidated.
The client will automatically reconnect and re-authenticate with
fresh data from the database.
"""
try:
session_ids = get_session_ids_from_room(f'user:{user_id}')
for sid in session_ids:
await sio.disconnect(sid)
if session_ids:
log.info(f'Disconnected {len(session_ids)} session(s) for user {user_id}')
except Exception as e:
log.warning(f'Failed to disconnect sessions for user {user_id}: {e}')
@sio.on('usage')
async def usage(sid, data):
if sid in SESSION_POOL:

View file

@ -1213,7 +1213,7 @@ async def search_channel_messages(
end_ts = end_timestamp * 1_000_000_000 if end_timestamp else None
# Search messages using the model method
matching_messages = Messages.search_messages_by_channel_ids(
matching_messages = await Messages.search_messages_by_channel_ids(
channel_ids=channel_ids,
query=query,
start_timestamp=start_ts,
@ -1274,7 +1274,7 @@ async def view_channel_message(
try:
user_id = __user__.get('id')
message = Messages.get_message_by_id(message_id)
message = await Messages.get_message_by_id(message_id)
if not message:
return json.dumps({'error': 'Message not found'})
@ -1336,7 +1336,7 @@ async def view_channel_thread(
user_id = __user__.get('id')
# Get the parent message
parent_message = Messages.get_message_by_id(parent_message_id)
parent_message = await Messages.get_message_by_id(parent_message_id)
if not parent_message:
return json.dumps({'error': 'Message not found'})
@ -1353,7 +1353,7 @@ async def view_channel_thread(
return json.dumps({'error': 'Access denied'})
# Get all thread replies
thread_replies = Messages.get_thread_replies_by_message_id(parent_message_id)
thread_replies = await Messages.get_thread_replies_by_message_id(parent_message_id)
# Build the response
messages = []

View file

@ -24,7 +24,7 @@ from asgiref.typing import (
from loguru import logger
from starlette.requests import Request
from open_webui.env import AUDIT_LOG_LEVEL, AUDIT_INCLUDED_PATHS, MAX_BODY_LOG_SIZE
from open_webui.env import AUDIT_LOG_LEVEL, ENABLE_AUDIT_GET_REQUESTS, AUDIT_INCLUDED_PATHS, MAX_BODY_LOG_SIZE
from open_webui.utils.auth import get_current_user, get_http_authorization_cred
from open_webui.models.users import UserModel
@ -117,7 +117,7 @@ class AuditLoggingMiddleware:
ASGI middleware that intercepts HTTP requests and responses to perform audit logging. It captures request/response bodies (depending on audit level), headers, HTTP methods, and user information, then logs a structured audit entry at the end of the request cycle.
"""
AUDITED_METHODS = {'PUT', 'PATCH', 'DELETE', 'POST'}
DEFAULT_AUDITED_METHODS = {'PUT', 'PATCH', 'DELETE', 'POST'}
def __init__(
self,
@ -127,12 +127,16 @@ class AuditLoggingMiddleware:
included_paths: Optional[list[str]] = None,
max_body_size: int = MAX_BODY_LOG_SIZE,
audit_level: AuditLevel = AuditLevel.NONE,
audit_get_requests: bool = False,
) -> None:
self.app = app
self.audit_logger = AuditLogger(logger)
self.excluded_paths = excluded_paths or []
self.included_paths = included_paths or []
self.max_body_size = max_body_size
self.audited_methods = set(self.DEFAULT_AUDITED_METHODS)
if audit_get_requests:
self.audited_methods.add('GET')
self.audit_level = audit_level
if self.included_paths and self.excluded_paths:
@ -202,7 +206,10 @@ class AuditLoggingMiddleware:
return None
def _should_skip_auditing(self, request: Request) -> bool:
if request.method not in {'POST', 'PUT', 'PATCH', 'DELETE'} or AUDIT_LOG_LEVEL == 'NONE':
if AUDIT_LOG_LEVEL == 'NONE':
return True
if request.method not in self.audited_methods:
return True
ALWAYS_LOG_ENDPOINTS = {

View file

@ -427,6 +427,26 @@ async def get_current_user_by_api_key(request, api_key: str):
):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
# Enforce endpoint restrictions — checked here (not in middleware)
# so it applies regardless of how the API key was transported
# (Authorization header, cookie, x-api-key header, etc.).
if request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
allowed_paths = [
path.strip()
for path in str(request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS).split(',')
if path.strip()
]
request_path = request.url.path
is_allowed = any(
request_path == allowed or request_path.startswith(allowed + '/')
for allowed in allowed_paths
)
if not is_allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Add user info to current span
if ENABLE_OTEL:
from opentelemetry import trace

View file

@ -224,6 +224,16 @@ def _resolve_model_filter_ids(app, model_id: str) -> list[str]:
return list(filter_ids) if filter_ids else []
def _resolve_model_terminal_id(app, model_id: str) -> Optional[str]:
"""Read model default terminal_id from model config.
The frontend does this in Chat.svelte (model.info.meta.terminalId).
"""
models = getattr(app.state, 'MODELS', {})
model = models.get(model_id, {})
return model.get('info', {}).get('meta', {}).get('terminalId') or None
async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -> None:
"""Set the working directory on a terminal server via the proxy.
@ -357,13 +367,8 @@ async def execute_automation(app, automation: AutomationModel) -> None:
features = _resolve_model_features(app, model_id)
filter_ids = _resolve_model_filter_ids(app, model_id)
# If a terminal is linked, set the CWD before building the payload
terminal_id = None
if terminal_config and terminal_config.get('server_id'):
terminal_id = terminal_config['server_id']
cwd = terminal_config.get('cwd')
if cwd:
await _set_terminal_cwd(app, terminal_id, user, cwd, chat.id)
# Resolve terminal from model config
terminal_id = _resolve_model_terminal_id(app, model_id)
# Build the same payload the frontend sends to /api/chat/completions
form_data = {

View file

@ -4060,11 +4060,12 @@ async def streaming_chat_response_handler(response, ctx):
if responses_api_tool_calls:
tool_calls.append(_split_tool_calls(responses_api_tool_calls))
try:
await stream_body_handler(response, form_data)
finally:
if response.background:
await response.background()
await stream_body_handler(response, form_data)
tool_call_retries = 0
tool_call_sources = [] # Track citation sources from tool results
all_tool_call_sources = [] # Accumulated sources across all iterations

View file

@ -910,9 +910,11 @@ async def cleanup_response(
session: Optional[aiohttp.ClientSession],
):
if response:
response.close()
if not response.closed:
await response.close()
if session:
await session.close()
if not session.closed:
await session.close()
async def stream_wrapper(response, session, content_handler=None):

View file

@ -0,0 +1,114 @@
"""Shared aiohttp ClientSession pool.
Instead of creating a new ClientSession (and TCPConnector) per request,
callers acquire a long-lived session from this module. The pool manages
a single TCPConnector with configurable limits, enabling TCP/SSL connection
reuse, shared DNS cache, and bounded concurrency.
All pool parameters are configurable via environment variables:
- AIOHTTP_POOL_CONNECTIONS (default 100) max total connections
- AIOHTTP_POOL_CONNECTIONS_PER_HOST (default 30) per-host limit
- AIOHTTP_POOL_DNS_TTL (default 300) DNS cache TTL in seconds
Usage:
from open_webui.utils.session_pool import get_session, cleanup_response
session = await get_session()
r = await session.request(...)
# When done with the *response* (not the session):
await cleanup_response(r)
IMPORTANT: Callers must NOT close the shared session. Only the response
needs cleanup. The session is closed once during application shutdown
via ``close_session()``.
"""
import logging
from typing import Optional
import aiohttp
from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_POOL_CONNECTIONS,
AIOHTTP_POOL_CONNECTIONS_PER_HOST,
AIOHTTP_POOL_DNS_TTL,
)
log = logging.getLogger(__name__)
_session: Optional[aiohttp.ClientSession] = None
async def get_session() -> aiohttp.ClientSession:
"""Return the shared aiohttp ClientSession, creating it lazily."""
global _session
if _session is None or _session.closed:
connector_kwargs = {
'ttl_dns_cache': AIOHTTP_POOL_DNS_TTL,
'enable_cleanup_closed': True,
}
if AIOHTTP_POOL_CONNECTIONS is not None:
connector_kwargs['limit'] = AIOHTTP_POOL_CONNECTIONS
else:
connector_kwargs['limit'] = 0 # aiohttp: 0 = unlimited
if AIOHTTP_POOL_CONNECTIONS_PER_HOST is not None:
connector_kwargs['limit_per_host'] = AIOHTTP_POOL_CONNECTIONS_PER_HOST
else:
connector_kwargs['limit_per_host'] = 0 # aiohttp: 0 = unlimited
connector = aiohttp.TCPConnector(**connector_kwargs)
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
_session = aiohttp.ClientSession(
connector=connector,
timeout=timeout,
trust_env=True,
)
log.info(
'Created shared aiohttp session pool '
'(limit=%s, per_host=%s, dns_ttl=%d)',
AIOHTTP_POOL_CONNECTIONS or 'unlimited',
AIOHTTP_POOL_CONNECTIONS_PER_HOST or 'unlimited',
AIOHTTP_POOL_DNS_TTL,
)
return _session
async def close_session():
"""Close the shared session. Called during application shutdown."""
global _session
if _session and not _session.closed:
await _session.close()
log.info('Closed shared aiohttp session pool')
_session = None
async def cleanup_response(
response: Optional[aiohttp.ClientResponse],
session: Optional[aiohttp.ClientSession] = None,
):
"""Release and close an aiohttp response, optionally closing the session.
When using the shared pool, ``session`` should be ``None`` (the pool
session is never closed per-request). When a caller creates its own
one-off session, pass it here to close it after the response.
"""
if response:
if not response.closed:
await response.close()
if session:
if not session.closed:
await session.close()
async def stream_wrapper(response, session=None, content_handler=None):
"""Wrap a stream to ensure cleanup happens even if streaming is interrupted.
This is more reliable than BackgroundTask which may not run if the client
disconnects. When using the shared pool, ``session`` should be ``None``.
"""
try:
stream = content_handler(response.content) if content_handler else response.content
async for chunk in stream:
yield chunk
finally:
await cleanup_response(response, session)

View file

@ -806,7 +806,7 @@ def convert_openapi_to_tool_payload(openapi_spec):
if not description:
description = param.get('description') or ''
if param_schema.get('enum') and isinstance(param_schema.get('enum'), list):
description += f'. Possible values: {", ".join(param_schema.get("enum"))}'
description += f'. Possible values: {", ".join(str(v) for v in param_schema.get("enum"))}'
param_property = {
'type': param_schema.get('type') or 'string',
'description': description,

View file

@ -1,13 +1,31 @@
"""Validation utilities for user-supplied input."""
# Known static asset paths used as default profile images
_ALLOWED_STATIC_PATHS = (
'/user.png',
'/static/favicon.png',
import re
from urllib.parse import urlparse
# Matches the OWUI-generated profile image route. ``[^/?#]+`` accepts
# any user-ID without allowing path-traversal or query/fragment injection,
# and the ``$`` anchor rejects trailing path components.
_USER_PROFILE_IMAGE_RE = re.compile(r'^/api/v1/users/[^/?#]+/profile/image$')
# Validates MIME type and structure of base64 data URIs. Only the prefix
# is checked — validating the full base64 payload would mean running a
# regex across megabytes of data on every Pydantic instantiation for zero
# security benefit (corrupt base64 simply renders a broken image, same as
# a 404 URL). SVG is intentionally excluded: it can carry embedded scripts.
_SAFE_DATA_URI_RE = re.compile(
r'^data:image/(png|jpeg|gif|webp);base64,', re.IGNORECASE
)
# External URL prefixes that are explicitly trusted for profile images
_ALLOWED_URL_PREFIXES = ('https://www.gravatar.com/avatar/',)
# Exact relative paths accepted as profile images. These are the only
# static-asset paths OWUI itself assigns; no prefix/wildcard matching is
# used so that arbitrary relative paths cannot trigger authenticated GETs
# against internal endpoints when rendered as ``<img>`` sources.
_SAFE_STATIC_PATHS = frozenset({
'/user.png',
'/favicon.png',
'/static/favicon.png',
})
def validate_profile_image_url(url: str) -> str:
@ -16,28 +34,51 @@ def validate_profile_image_url(url: str) -> str:
Allowed formats:
- Empty string (falls back to default avatar)
- data:image/* URIs (base64-encoded uploads from the frontend)
- Known static asset paths (/user.png, /static/favicon.png)
- Trusted external URLs (e.g. Gravatar)
- Known static-asset paths assigned by OWUI (exact match)
- The OWUI profile-image API route ``/api/v1/users/{id}/profile/image``
- ``http://`` and ``https://`` URLs with a valid hostname
- ``data:image/{png,jpeg,gif,webp};base64,...`` URIs
Returns the url unchanged if valid, raises ValueError otherwise.
Everything else is rejected, including:
- Dangerous schemes (javascript:, file:, ftp:, )
- SVG data URIs (can contain embedded scripts)
- Arbitrary relative paths (prevents authenticated GET triggers)
- Scheme-relative URLs (``//host/path``)
"""
if not url:
return url
_ALLOWED_DATA_PREFIXES = (
'data:image/png',
'data:image/jpeg',
'data:image/gif',
'data:image/webp',
# --- Relative paths (exact match + anchored regex only) -----------
if url in _SAFE_STATIC_PATHS:
return url
if _USER_PROFILE_IMAGE_RE.match(url):
return url
# --- Absolute URLs -------------------------------------------------
# urlparse normalises the scheme to lowercase, giving us
# case-insensitive scheme matching for free.
parsed = urlparse(url)
# External images served over HTTP(S), e.g. OAuth provider avatars.
# Require a non-empty hostname (not just netloc, which can be ":80"
# for a URL like http://:80/path with no actual host).
if parsed.scheme in ('http', 'https'):
if not parsed.hostname:
raise ValueError(
'Invalid profile image URL: HTTP(S) URLs must include a host.'
)
return url
# Base64-encoded raster images uploaded via the frontend.
# The regex enforces the ;base64, boundary and is case-insensitive
# per the data-URI / MIME-type specs.
if _SAFE_DATA_URI_RE.match(url):
return url
raise ValueError(
'Invalid profile image URL: must be a known internal path, '
'an HTTP(S) URL with a host, or a data:image URI (png/jpeg/gif/webp).'
)
if any(url.startswith(prefix) for prefix in _ALLOWED_DATA_PREFIXES):
return url
if url in _ALLOWED_STATIC_PATHS:
return url
if any(url.startswith(prefix) for prefix in _ALLOWED_URL_PREFIXES):
return url
raise ValueError('Invalid profile image URL: only data URIs and default avatars are allowed.')

View file

@ -273,10 +273,42 @@ export const stopTask = async (token: string, id: string) => {
return res;
};
export const stopTasksByChatId = async (token: string, chat_id: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/tasks/chat/${encodeURIComponent(chat_id)}/stop`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const getTaskIdsByChatId = async (token: string, chat_id: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/tasks/chat/${chat_id}`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/tasks/chat/${encodeURIComponent(chat_id)}`, {
method: 'GET',
headers: {
Accept: 'application/json',

View file

@ -8,7 +8,6 @@
import ScheduleDropdown from '$lib/components/automations/ScheduleDropdown.svelte';
import ModelDropdown from '$lib/components/automations/ModelDropdown.svelte';
import TerminalDropdown from '$lib/components/automations/TerminalDropdown.svelte';
import {
createAutomation,
@ -16,7 +15,6 @@
type AutomationForm,
type AutomationResponse
} from '$lib/apis/automations';
import { getTerminalServers, type TerminalServer } from '$lib/apis/terminal/index';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@ -31,11 +29,6 @@
let loading = false;
// Terminal state
let terminalServers: TerminalServer[] = [];
let terminalServerId = '';
let terminalCwd = '';
// Schedule dropdown ref
let scheduleDropdown: ScheduleDropdown;
@ -58,15 +51,7 @@
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
rrule: scheduleDropdown.buildRrule(),
...(terminalServerId
? {
terminal: {
server_id: terminalServerId,
...(terminalCwd.trim() ? { cwd: terminalCwd.trim() } : {})
}
}
: {})
rrule: scheduleDropdown.buildRrule()
},
is_active
};
@ -90,20 +75,11 @@
};
const init = async () => {
// Load terminal servers
try {
terminalServers = await getTerminalServers(localStorage.token);
} catch {
terminalServers = [];
}
if (automation) {
name = automation.name;
prompt = automation.data.prompt;
model_id = automation.data.model_id;
is_active = automation.is_active;
terminalServerId = automation.data.terminal?.server_id || '';
terminalCwd = automation.data.terminal?.cwd || '';
if (scheduleDropdown) {
scheduleDropdown.parseRrule(automation.data.rrule);
}
@ -112,8 +88,6 @@
prompt = '';
model_id = '';
is_active = true;
terminalServerId = '';
terminalCwd = '';
}
};
@ -158,14 +132,6 @@
<ScheduleDropdown bind:this={scheduleDropdown} side="top" align="start" />
<ModelDropdown bind:model_id side="top" align="start" />
<TerminalDropdown
{terminalServers}
bind:terminalServerId
bind:terminalCwd
side="top"
align="start"
/>
</div>
<div class="flex items-center gap-2 shrink-0">

View file

@ -19,7 +19,7 @@
type AutomationResponse,
type AutomationRunModel
} from '$lib/apis/automations';
import { getTerminalServers, type TerminalServer } from '$lib/apis/terminal/index';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -29,7 +29,6 @@
import ScheduleDropdown from '$lib/components/automations/ScheduleDropdown.svelte';
import ModelDropdown from '$lib/components/automations/ModelDropdown.svelte';
import TerminalDropdown from '$lib/components/automations/TerminalDropdown.svelte';
dayjs.extend(relativeTime);
dayjs.extend(localizedFormat);
@ -43,9 +42,6 @@
let model_id = '';
let is_active = true;
let terminalServers: TerminalServer[] = [];
let terminalServerId = '';
let terminalCwd = '';
let loading = false;
let saving = false;
@ -97,15 +93,7 @@
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
rrule: scheduleDropdown.buildRrule(),
...(terminalServerId
? {
terminal: {
server_id: terminalServerId,
...(terminalCwd.trim() ? { cwd: terminalCwd.trim() } : {})
}
}
: {})
rrule: scheduleDropdown.buildRrule()
},
is_active
};
@ -204,18 +192,11 @@
prompt = automation.data.prompt;
model_id = automation.data.model_id;
is_active = automation.is_active;
terminalServerId = automation.data.terminal?.server_id || '';
terminalCwd = automation.data.terminal?.cwd || '';
if (scheduleDropdown) {
scheduleDropdown.parseRrule(automation.data.rrule);
}
try {
terminalServers = await getTerminalServers(localStorage.token);
} catch {
terminalServers = [];
}
await loadRuns();
});
</script>
@ -355,20 +336,6 @@
<ModelDropdown bind:model_id side="bottom" align="end" onChange={markDirty} />
</div>
<!-- Terminal -->
{#if terminalServers.length > 0}
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('Terminal')}</span>
<TerminalDropdown
{terminalServers}
bind:terminalServerId
bind:terminalCwd
side="bottom"
align="end"
onChange={markDirty}
/>
</div>
{/if}
</div>
</div>

View file

@ -86,6 +86,7 @@
chatAction,
generateMoACompletion,
stopTask,
stopTasksByChatId,
getTaskIdsByChatId
} from '$lib/apis';
import { getTools } from '$lib/apis/tools';
@ -370,6 +371,11 @@
codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter');
}
}
// Set Default Terminal
if (model?.info?.meta?.terminalId) {
selectedTerminalId.set(model.info.meta.terminalId);
}
}
};
@ -2459,11 +2465,18 @@
const stopResponse = async (processQueue = true) => {
if (taskIds) {
for (const taskId of taskIds) {
const res = await stopTask(localStorage.token, taskId).catch((error) => {
if ($chatId) {
await stopTasksByChatId(localStorage.token, $chatId).catch((error) => {
toast.error(`${error}`);
return null;
});
} else {
for (const taskId of taskIds) {
const res = await stopTask(localStorage.token, taskId).catch((error) => {
toast.error(`${error}`);
return null;
});
}
}
taskIds = null;

View file

@ -109,7 +109,7 @@
<del><svelte:self id={`${id}-del`} tokens={token.tokens} {onSourceClick} /></del>
{:else if token.type === 'inlineKatex'}
{#if token.text}
<KatexRenderer content={token.text} displayMode={false} />
<KatexRenderer content={token.text} displayMode={token?.displayMode ?? false} />
{/if}
{:else if token.type === 'iframe'}
<iframe

View file

@ -1430,6 +1430,7 @@
: ''}"
style="fill: currentColor;"
alt={action.name}
draggable="false"
/>
</div>
{:else}

View file

@ -25,6 +25,7 @@
import DefaultFeatures from './DefaultFeatures.svelte';
import BuiltinTools from './BuiltinTools.svelte';
import PromptSuggestions from './PromptSuggestions.svelte';
import TerminalSelector from './TerminalSelector.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import { updateModelAccessGrants } from '$lib/apis/models';
@ -102,6 +103,7 @@
let actionIds = [];
let accessGrants = [];
let terminalId = '';
let tts = { voice: '' };
const submitHandler = async () => {
@ -206,6 +208,14 @@
}
}
if (terminalId) {
info.meta.terminalId = terminalId;
} else {
if (info.meta.terminalId) {
delete info.meta.terminalId;
}
}
if (tts.voice !== '') {
if (!info.meta.tts) info.meta.tts = {};
info.meta.tts.voice = tts.voice;
@ -316,6 +326,7 @@
capabilities = { ...capabilities, ...(model?.meta?.capabilities ?? {}) };
defaultFeatureIds = model?.meta?.defaultFeatureIds ?? defaultFeatureIds;
builtinTools = model?.meta?.builtinTools ?? builtinTools;
terminalId = model?.meta?.terminalId ?? '';
tts = { voice: model?.meta?.tts?.voice ?? '' };
accessGrants = model?.access_grants ?? [];
@ -828,6 +839,10 @@
</div>
{/if}
<div class="my-4">
<TerminalSelector bind:terminalId />
</div>
<div class="my-4">
<div class="flex w-full justify-between mb-1">
<div class="self-center text-xs font-medium text-gray-500">

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { getTerminalServers, type TerminalServer } from '$lib/apis/terminal';
const i18n = getContext('i18n');
export let terminalId: string = '';
let terminals: TerminalServer[] = [];
onMount(async () => {
terminals = await getTerminalServers(localStorage.token);
});
</script>
{#if terminals.length > 0}
<div class="flex w-full justify-between mb-1">
<div class="self-center text-xs font-medium text-gray-500">{$i18n.t('Terminal')}</div>
</div>
<select
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
bind:value={terminalId}
>
<option value="">{$i18n.t('None')}</option>
{#each terminals as terminal (terminal.id)}
<option value={terminal.id}>{terminal.name || terminal.id}</option>
{/each}
</select>
{/if}

View file

@ -183,7 +183,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "Tem certeza de que deseja arquivar todos os chats? Esta ação não pode ser desfeita.",
"Are you sure you want to clear all memories? This action cannot be undone.": "Tem certeza de que deseja apagar todas as memórias? Esta ação não pode ser desfeita.",
"Are you sure you want to delete \"{{NAME}}\"?": "Tem certeza de que deseja excluir \"{{NAME}}\"?",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete **{{modelName}}**?": "Tem certeza de que deseja excluir **{{modelName}}**?",
"Are you sure you want to delete all chats? This action cannot be undone.": "Tem certeza de que deseja excluir todas as conversas? Esta ação não pode ser desfeita.",
"Are you sure you want to delete this channel?": "Tem certeza de que deseja excluir este canal?",
"Are you sure you want to delete this connection? This action cannot be undone.": "Tem certeza de que deseja excluir esta conexão? Esta ação não pode ser desfeita.",
@ -200,7 +200,7 @@
"Assistant": "Assistente",
"Async Embedding Processing": "Processamento de Embedding assíncrono",
"Attach File From Knowledge": "Anexar arquivo da base de conhecimento",
"Attach Files": "",
"Attach Files": "Anexar arquivos",
"Attach Knowledge": "Anexar Base de Conhecimento",
"Attach Notes": "Anexar Notas",
"Attach Webpage": "Anexar Página Web",
@ -223,13 +223,13 @@
"AUTOMATIC1111 Base URL": "URL Base AUTOMATIC1111",
"AUTOMATIC1111 Base URL is required.": "URL Base AUTOMATIC1111 é necessária.",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "Injetar automaticamente ferramentas do sistema no modo de chamada de função nativa (por exemplo, carimbos de data/hora, memória, histórico de chat, notas, etc.)",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Automation": "Automação",
"Automation created": "Automação criada",
"Automation Name": "Nome da automação",
"Automation title": "Título de automação",
"Automation triggered": "Automação acionada",
"Automation updated": "Automação atualizada",
"Automations": "Automações",
"Available list": "Lista disponível",
"Available models": "Modelos disponíveis",
"Available Tools": "Ferramentas disponíveis",
@ -260,7 +260,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "Aumentar ou penalizar tokens específicos para respostas restritas. Os valores de viés serão fixados entre -100 e 100 (inclusive). (Padrão: nenhum)",
"Brave": "Brave",
"Brave Search API Key": "Chave API do Brave Search",
"Break down complex requests into trackable steps": "",
"Break down complex requests into trackable steps": "Divida solicitações complexas em etapas rastreáveis.",
"Browse and query knowledge bases": "Navegue e consulte bases de conhecimento.",
"Builtin Tools": "Ferramentas integradas",
"Bullet List": "Lista com marcadores",
@ -394,7 +394,7 @@
"Concurrent Requests": "Solicitações simultâneas",
"Config": "Configuração",
"Config imported successfully": "Configuração importada com sucesso",
"Configuration": "",
"Configuration": "Configuração",
"Configure": "Configurar",
"Confirm": "Confirmar",
"Confirm Password": "Confirmar Senha",
@ -464,7 +464,7 @@
"Create new secret key": "Criar nova chave secreta",
"Create note": "Criar nota",
"Create Note": "Criar Nota",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create scheduled prompts that run automatically on a recurring basis.": "Crie prompts agendados que sejam executados automaticamente de forma recorrente.",
"Create your first note by clicking on the plus button below.": "Crie sua primeira nota clicando no botão de adição abaixo.",
"Created at": "Criado em",
"Created At": "Criado Em",
@ -486,7 +486,7 @@
"Data Controls": "Controle de Dados",
"Database": "Banco de Dados",
"Datalab Marker API": "API do Marcador do Datalab",
"Day": "",
"Day": "Dia",
"DD/MM/YYYY": "DD/MM/AAAA",
"DDGS Backend": "Backend DDGS",
"December": "Dezembro",
@ -517,7 +517,7 @@
"Delete All": "Excluir tudo",
"Delete All Chats": "Excluir Todos os Chats",
"Delete all contents inside this folder": "Apague todo o conteúdo desta pasta.",
"Delete automation?": "",
"Delete automation?": "Excluir automação?",
"Delete Chat": "Excluir Chat",
"Delete chat?": "Excluir chat?",
"Delete File": "Excluir arquivo",
@ -662,7 +662,7 @@
"Embedding Concurrent Requests": "Solicitações Simultâneas de Embedding",
"Embedding Model": "Modelo de Embedding",
"Embedding Model Engine": "Motor do Modelo de Embedding",
"Emojis": "",
"Emojis": "Emojis",
"Empty message": "Mensagem vazia",
"Enable All": "Ativar tudo",
"Enable API Keys": "Habilitar Chaves de API",
@ -754,7 +754,7 @@
"Enter Perplexity Search API URL": "Insira a URL da API de pesquisa Perplexity",
"Enter Playwright Timeout": "Insira o tempo limite do Playwright",
"Enter Playwright WebSocket URL": "Insira a URL do WebSocket do Playwright",
"Enter prompt here.": "",
"Enter prompt here.": "Insira o prompt aqui.",
"Enter proxy URL (e.g. https://user:password@host:port)": "Insira a URL do proxy (por exemplo, https://usuário:senha@host:porta)",
"Enter reasoning effort": "Insira o esforço de raciocínio",
"Enter Score": "Digite a Pontuação",
@ -779,7 +779,7 @@
"Enter system prompt here": "Insira o prompt do sistema aqui",
"Enter Tavily API Key": "Digite a Chave API do Tavily",
"Enter Tavily Extract Depth": "Insira a profundidade de extração do Tavily",
"Enter the prompt instructions for this automation...": "",
"Enter the prompt instructions for this automation...": "Insira as instruções para esta automação...",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "Insira a URL pública da sua WebUI. Esta URL será usada para gerar links nas notificações.",
"Enter the URL of the function to import": "Digite a URL da função a ser importada",
"Enter the URL to import": "Digite a URL para importar",
@ -819,7 +819,7 @@
"Error accessing directory": "Erro ao acessar o diretório",
"Error accessing Google Drive: {{error}}": "Erro ao acessar o Google Drive: {{error}}",
"Error accessing media devices.": "Erro ao acessar dispositivos de mídia.",
"Error deleting model: {{error}}": "",
"Error deleting model: {{error}}": "Erro ao excluir o modelo: {{error}}",
"Error starting recording.": "Erro ao iniciar a gravação.",
"Error unloading model: {{error}}": "Erro ao descarregar modelo: {{error}}",
"Error uploading file: {{error}}": "Erro ao carregar o arquivo: {{error}}",
@ -837,7 +837,7 @@
"Execute code": "Executar código",
"Execute code for analysis": "Executar código para análise",
"Executing **{{NAME}}**...": "Executando **{{NAME}}**...",
"Execution Logs": "",
"Execution Logs": "Registros de execução",
"Expand": "Expandir",
"Experimental": "Experimental",
"Explain": "Explicar",
@ -847,8 +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 as CSV": "Exportar como CSV",
"Export as JSON": "Exportar como JSON",
"Export chat (.json)": "Exportar chat (.json)",
"Export Chats": "Exportar Chats",
"Export Config": "Exportar Configuração",
@ -1103,7 +1103,7 @@
"Insert Suggestion Prompt to Input": "Inserir prompt de sugestão para entrada",
"Install from Github URL": "Instalar da URL do Github",
"Instant Auto-Send After Voice Transcription": "Envio Automático Instantâneo Após Transcrição de Voz",
"Instructions": "",
"Instructions": "Instruções",
"Integration": "Integração",
"Integrations": "Integrações",
"Interface": "Interface",
@ -1163,7 +1163,7 @@
"Last 90 days": "Últimos 90 dias",
"Last Active": "Última Atividade",
"Last Modified": "Última Modificação",
"Last ran": "",
"Last ran": "Última execução",
"Last reply": "Última resposta",
"LDAP": "LDAP",
"LDAP server updated": "Servidor LDAP atualizado",
@ -1270,7 +1270,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "Modelo '{{modelName}}' foi baixado com sucesso.",
"Model '{{modelTag}}' is already in queue for downloading.": "Modelo '{{modelTag}}' já está na fila para download.",
"Model {{modelId}} not found": "Modelo {{modelId}} não encontrado",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} deleted successfully": "Modelo {{modelName}} excluído com sucesso",
"Model {{modelName}} is not vision capable": "Modelo {{modelName}} não é capaz de visão",
"Model {{name}} is now {{status}}": "Modelo {{name}} está agora {{status}}",
"Model {{name}} is now hidden": "O modelo {{name}} agora está oculto",
@ -1320,11 +1320,11 @@
"Name": "Nome",
"Name and ID are required, please fill them out": "Nome e ID são obrigatórios, por favor preencha-os",
"Name your knowledge base": "Nome da sua base de conhecimento",
"Name, prompt, and model are required": "",
"Name, prompt, and model are required": "Nome, prompt e modelo são obrigatórios.",
"Native": "Nativo",
"Never": "",
"Never": "Nunca",
"New": "Novo",
"New Automation": "",
"New Automation": "Nova Automação",
"New Button": "Novo Botão",
"New Chat": "Novo Chat",
"New File": "Novo Arquivo",
@ -1343,11 +1343,11 @@
"New Webhook": "Novo Webhook",
"new-channel": "novo-canal",
"Next message": "Próxima mensagem",
"Next run": "",
"Next run": "Próxima execução",
"No access grants. Private to you.": "Sem permissões de acesso. Privacidade exclusiva para você.",
"No activity data": "Sem dados de atividade",
"No authentication": "Sem autenticação",
"No automations found": "",
"No automations found": "Nenhuma automação encontrada",
"No chats found": "Nenhum chat encontrado",
"No chats found for this user.": "Nenhum chat encontrado para este usuário.",
"No chats found.": "Nenhum chat encontrado.",
@ -1358,7 +1358,7 @@
"No data": "Sem dados",
"No data found": "Nenhum dado encontrado",
"No distance available": "Sem distância disponível",
"No execution logs available yet": "",
"No execution logs available yet": "Ainda não há registros de execução disponíveis.",
"No expiration can pose security risks.": "A ausência de expiração pode representar riscos de segurança.",
"No feedback found": "Nenhum feedback encontrado",
"No file selected": "Nenhum arquivo selecionado",
@ -1405,7 +1405,7 @@
"Not factually correct": "Não está factualmente correto",
"Not helpful": "Não é útil",
"Not Registered": "Não registrado",
"Not scheduled": "",
"Not scheduled": "Não agendado",
"Note": "Nota",
"Note deleted successfully": "Nota excluída com sucesso",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Nota: Se você definir uma pontuação mínima, a pesquisa retornará apenas documentos com pontuação igual ou superior à pontuação mínima.",
@ -1495,7 +1495,7 @@
"Password": "Senha",
"Passwords do not match.": "As senhas não coincidem.",
"Paste Large Text as File": "Cole Textos Longos como Arquivo",
"Paused": "",
"Paused": "Em pausa",
"PDF document (.pdf)": "Documento PDF (.pdf)",
"PDF Extract Images (OCR)": "Extrair Imagens do PDF (OCR)",
"PDF Loader Mode": "Modo de carregamento de PDF",
@ -1600,7 +1600,7 @@
"Reason": "Razão",
"Reasoning Effort": "Esforço de raciocínio",
"Reasoning Tags": "Tags de raciocínio",
"Recently Used": "",
"Recently Used": "Usado recentemente",
"Record": "Gravar",
"Record voice": "Gravar voz",
"Redirecting you to Open WebUI Community": "Redirecionando você para a Comunidade OpenWebUI",
@ -1636,7 +1636,7 @@
"Renamed to {{name}}": "Renomeado para {{name}}",
"Render Markdown in Previews": "Renderizar Markdown nas Pré-visualizações",
"Reorder Models": "Reordenar modelos",
"Repeats": "",
"Repeats": "Repetições",
"Reply": "Responder",
"Reply in Thread": "Responder no tópico",
"Reply to thread...": "Responder ao tópico...",
@ -1670,8 +1670,8 @@
"RTL": "Direita para Esquerda",
"Run": "Executar",
"Run All": "Executar Tudo",
"Run now": "",
"Run Now": "",
"Run now": "Executar agora",
"Run Now": "Executar Agora",
"Running": "Executando",
"Running...": "Executando...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Executa tarefas de incorporação simultaneamente para acelerar o processamento. Desative se os limites de taxa se tornarem um problema.",
@ -1682,15 +1682,15 @@
"Save Chat": "Salvar Chat",
"Saved": "Armazenado",
"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": "Salvar registros de chat diretamente no armazenamento do seu navegador não é mais suportado. Por favor, reserve um momento para baixar e excluir seus registros de chat clicando no botão abaixo. Não se preocupe, você pode facilmente reimportar seus registros de chat para o backend através de",
"Schedule": "",
"Scheduled time must be in the future": "",
"Schedule": "Agendar",
"Scheduled time must be in the future": "O horário agendado deve ser no futuro.",
"Scroll On Branch Change": "Rolar na mudança de ramo",
"Search": "Pesquisar",
"Search a model": "Pesquisar um modelo",
"Search all emojis": "Pesquisar todos os emojis",
"Search and manage user memories": "Pesquisar e gerenciar memórias de usuários",
"Search and view user chat history": "Pesquise e visualize o histórico de chat do usuário",
"Search Automations": "",
"Search Automations": "Pesquisar Automações",
"Search Base": "Pesquisar Base",
"Search channels and channel messages": "Pesquisar canais e mensagens de canais",
"Search Chats": "Pesquisar Chats",
@ -1760,7 +1760,7 @@
"Select how to split message text for TTS requests": "Selecione como dividir o texto da mensagem para solicitações TTS",
"Select Knowledge": "Selecionar Conhecimento",
"Select Method": "Selecione o método",
"Select model": "",
"Select model": "Selecione o modelo",
"Select only one model to call": "Selecione apenas um modelo para chamar",
"Select view": "Selecionar visualização",
"Selected model: {{modelName}}": "Modelo selecionado: {{modelName}}",
@ -1870,7 +1870,7 @@
"Start of the channel": "Início do canal",
"Start Tag": "Tag inicial",
"Starting kernel...": "Iniciando kernel...",
"State": "",
"State": "Estado",
"Status": "Status",
"Status cleared successfully": "Status liberado com sucesso",
"Status updated successfully": "Status atualizado com sucesso",
@ -1921,10 +1921,10 @@
"Talk to Model": "Fale com o modelo",
"Tap to interrupt": "Toque para interromper",
"Task List": "Lista de tarefas",
"Task Management": "",
"Task Management": "Gerenciamento de Tarefas",
"Task Model": "Modelo de Tarefa",
"Tasks": "Tarefas",
"tasks completed": "",
"tasks completed": "tarefas concluídas",
"Tavily API Key": "Chave da API Tavily",
"Tavily Extract Depth": "Profundidade de extração do Tavily",
"Tell us more:": "Conte-nos mais:",
@ -1991,7 +1991,7 @@
"Tika": "Tika",
"Tika Server URL required.": "URL do servidor Tika necessária.",
"Tiktoken": "Tiktoken",
"Time": "",
"Time": "Tempo",
"Time & Calculation": "Tempo e Cálculo",
"Timeout": "Tempo limite",
"Title": "Título",
@ -2009,7 +2009,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "Para selecionar kits de ferramentas aqui, adicione-os ao espaço de trabalho \"Ferramentas\" primeiro.",
"Toast notifications for new updates": "Notificações de alerta para novas atualizações",
"Today": "Hoje",
"Today at": "",
"Today at": "Hoje em",
"Today at {{LOCALIZED_TIME}}": "Hoje às {{LOCALIZED_TIME}}",
"Toggle {{COUNT}} sources": "Alternar {{COUNT}} origens",
"Toggle 1 source": "Alternar 1 origem",
@ -2148,7 +2148,7 @@
"Waiting for upload...": "Aguardando upload...",
"Warning": "Aviso",
"Warning:": "Aviso:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "Aviso: Habilitar esta opção permitirá que os usuários executem solicitações agendadas automaticamente.",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "Aviso: Habilitar isso permitirá que os usuários façam upload de código arbitrário no servidor.",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "Aviso: a execução do Jupyter permite a execução de código arbitrário, o que representa sérios riscos de segurança. Prossiga com extremo cuidado.",
"Web": "Web",
@ -2183,7 +2183,7 @@
"Width": "Largura",
"Wikipedia": "Wikipédia",
"Won": "Ganhou",
"Working Directory": "",
"Working Directory": "Diretório de Trabalho",
"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.": "Funciona em conjunto com o top-k. Um valor mais alto (por exemplo, 0,95) resultará em um texto mais diverso, enquanto um valor mais baixo (por exemplo, 0,5) gerará um texto mais focado e conservador.",
"Workspace": "Espaço de Trabalho",
"Workspace Permissions": "Permissões do espaço de trabalho",

View file

@ -66,6 +66,46 @@ function generateRegexRules(delimiters) {
const { inlineRule, blockRule } = generateRegexRules(DELIMITER_LIST);
const isAllowedTrailing = (src: string, i: number): boolean =>
i >= src.length || ALLOWED_SURROUNDING_CHARS_REGEX.test(src.charAt(i));
const isBlockBoundary = (src: string, i: number): boolean =>
/^(?:[ \t]*\r?\n|$)/.test(src.slice(i));
const findClosingDelimiter = (src: string, i: number): number =>
i >= src.length - 1
? -1
: src[i] === '\\'
? findClosingDelimiter(src, i + 2)
: src[i] === '$' && src[i + 1] === '$'
? i
: findClosingDelimiter(src, i + 1);
export const tokenizeDisplayMath = (
src: string,
type: 'inlineKatex' | 'blockKatex',
requireBlockBoundary = false
) => {
if (!src.startsWith('$$')) return;
const endIndex = findClosingDelimiter(src, 2);
if (endIndex === -1) return;
const raw = src.slice(0, endIndex + 2);
const text = raw.slice(2, -2);
const afterClose = endIndex + 2;
const validators: Array<() => boolean> = [
() => text.trim().length > 0,
() => isAllowedTrailing(src, afterClose),
() => !requireBlockBoundary || isBlockBoundary(src, afterClose)
];
return validators.every((v) => v())
? { type, raw, text, displayMode: true }
: undefined;
};
export default function (options = {}) {
return {
extensions: [inlineKatex(options), blockKatex(options)]
@ -102,6 +142,17 @@ function katexStart(src, displayMode: boolean) {
}
function katexTokenizer(src, tokens, displayMode: boolean) {
if (src.startsWith('$$')) {
const displayToken = tokenizeDisplayMath(
src,
displayMode ? 'blockKatex' : 'inlineKatex',
displayMode
);
if (displayToken) {
return displayToken;
}
}
const ruleReg = displayMode ? blockRule : inlineRule;
const type = displayMode ? 'blockKatex' : 'inlineKatex';