diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 9a16fd4ba8..57b2bd564a 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -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 diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index d959351d4c..716d0c92a5 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -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 diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index c9442f208b..cc520ffe63 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -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): diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 484212a493..6652ebf44d 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -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) diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index 504bc726d2..9c532a8915 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -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) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 6610ee2eca..5c2ab9dcac 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -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()) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 99535cd296..3b52a30e17 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -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, diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index fb3bc1cec5..0d534db7f6 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -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:') diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 75ccb98e20..6c7bdc64dc 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -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 diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 11c916846a..93b34f5e66 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -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): diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 51d4267c38..0bfddf47d1 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -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) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 091143a5d5..3253ab3707 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -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( diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index d2ddd90b19..2c44eb25c5 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -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: diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 58af934372..25d1f84413 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -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 = [] diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index 1200d813af..5686c88d5d 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -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 = { diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 32e7db3423..9ac7524411 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -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 diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index 262430dcf0..45b7ba65ab 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -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 = { diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index fb4912bef5..ce75cd15fc 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -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 diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 6c99f02905..fe81b61aa3 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -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): diff --git a/backend/open_webui/utils/session_pool.py b/backend/open_webui/utils/session_pool.py new file mode 100644 index 0000000000..86ffa6cd9d --- /dev/null +++ b/backend/open_webui/utils/session_pool.py @@ -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) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index a44fe69ab8..fb2ec8ad30 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -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, diff --git a/backend/open_webui/utils/validate.py b/backend/open_webui/utils/validate.py index eec53317f5..4decac5eb7 100644 --- a/backend/open_webui/utils/validate.py +++ b/backend/open_webui/utils/validate.py @@ -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 ```` 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.') diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 05475b4b37..5faf56d4d4 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -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', diff --git a/src/lib/components/AutomationModal.svelte b/src/lib/components/AutomationModal.svelte index c1843f20e7..c16265515e 100644 --- a/src/lib/components/AutomationModal.svelte +++ b/src/lib/components/AutomationModal.svelte @@ -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 @@ - -
diff --git a/src/lib/components/automations/AutomationEditor.svelte b/src/lib/components/automations/AutomationEditor.svelte index fbdf448179..cb5859286a 100644 --- a/src/lib/components/automations/AutomationEditor.svelte +++ b/src/lib/components/automations/AutomationEditor.svelte @@ -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(); }); @@ -355,20 +336,6 @@
- - {#if terminalServers.length > 0} -
- {$i18n.t('Terminal')} - -
- {/if} diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index a3feb6a269..7e69b45122 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -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; diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte index c2c7d81e61..0daf389eec 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte @@ -109,7 +109,7 @@ {:else if token.type === 'inlineKatex'} {#if token.text} - + {/if} {:else if token.type === 'iframe'}