mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-12 23:02:35 +00:00
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:
commit
a2238f17b6
32 changed files with 601 additions and 283 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
114
backend/open_webui/utils/session_pool.py
Normal file
114
backend/open_webui/utils/session_pool.py
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1430,6 +1430,7 @@
|
|||
: ''}"
|
||||
style="fill: currentColor;"
|
||||
alt={action.name}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
30
src/lib/components/workspace/Models/TerminalSelector.svelte
Normal file
30
src/lib/components/workspace/Models/TerminalSelector.svelte
Normal 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}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue