diff --git a/.dockerignore b/.dockerignore index 2b4f7b5fcf..55ab35b985 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,3 +18,6 @@ uploads **/*.db _test backend/data/* + +.venv +.git diff --git a/Dockerfile b/Dockerfile index e5d7925e94..d9ddd4590f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -123,23 +123,30 @@ RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry # Make sure the user has access to the app and root directory RUN chown -R $UID:$GID /app $HOME -# Install common system dependencies +# Slim cannot bundle a local model server or GPU runtime. +RUN if [ "$USE_SLIM" = "true" ] && { [ "$USE_CUDA" = "true" ] || [ "$USE_OLLAMA" = "true" ]; }; then \ + echo "USE_SLIM cannot be combined with USE_CUDA or USE_OLLAMA" >&2; exit 1; fi + +# Keep the slim runtime free of local document/audio processing tools. RUN apt-get update && \ apt-get install -y --no-install-recommends \ - git build-essential pandoc gcc curl jq ca-certificates \ - libmariadb-dev \ - ffmpeg libsm6 libxext6 zstd \ - && rm -rf /var/lib/apt/lists/* + git curl jq ca-certificates zstd \ + && if [ "$USE_SLIM" != "true" ]; then \ + apt-get install -y --no-install-recommends \ + build-essential pandoc gcc libmariadb-dev ffmpeg libsm6 libxext6; \ + fi && rm -rf /var/lib/apt/lists/* # install python dependencies -COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt +COPY --chown=$UID:$GID ./backend/requirements*.txt ./ # Set UV_LINK_MODE to copy to prevent 0-byte file corruption in QEMU arm64 cross-builds ENV UV_LINK_MODE=copy RUN set -e; \ pip3 install --no-cache-dir uv; \ - if [ "$USE_CUDA" = "true" ]; then \ + if [ "$USE_SLIM" = "true" ]; then \ + uv pip install --system -r requirements-slim.txt --no-cache-dir; \ + elif [ "$USE_CUDA" = "true" ]; then \ # If you use CUDA the whisper and embedding model will be downloaded on first use # fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349 pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir; \ diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index ff77fbcf4e..822631ef69 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -17,6 +17,7 @@ from authlib.integrations.starlette_client import OAuth from pydantic import BaseModel from open_webui.env import ( + USE_SLIM, DATA_DIR, DATABASE_URL, ENABLE_ADMIN_CHAT_ACCESS, @@ -644,7 +645,7 @@ SSL_ASSERT_FINGERPRINT = os.getenv('SSL_ASSERT_FINGERPRINT', None) ELASTICSEARCH_INDEX_PREFIX = os.getenv('ELASTICSEARCH_INDEX_PREFIX', 'open_webui_collections') # Pgvector PGVECTOR_DB_URL = os.getenv('PGVECTOR_DB_URL', DATABASE_URL) -if VECTOR_DB == 'pgvector' and not PGVECTOR_DB_URL.startswith('postgres'): +if not USE_SLIM and VECTOR_DB == 'pgvector' and not PGVECTOR_DB_URL.startswith('postgres'): raise ValueError( 'Pgvector requires setting PGVECTOR_DB_URL or using Postgres with vector extension as the primary database.' ) @@ -805,7 +806,7 @@ ORACLE_DB_POOL_MAX = int(os.getenv('ORACLE_DB_POOL_MAX', 10)) ORACLE_DB_POOL_INCREMENT = int(os.getenv('ORACLE_DB_POOL_INCREMENT', 1)) -if VECTOR_DB == 'oracle23ai': +if not USE_SLIM and VECTOR_DB == 'oracle23ai': if not ORACLE_DB_USER or not ORACLE_DB_PASSWORD or not ORACLE_DB_DSN: raise ValueError('Oracle23ai requires setting ORACLE_DB_USER, ORACLE_DB_PASSWORD, and ORACLE_DB_DSN.') if ORACLE_DB_USE_WALLET and (not ORACLE_WALLET_DIR or not ORACLE_WALLET_PASSWORD): diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index c3a7da82e2..7380f3eb3e 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -42,12 +42,13 @@ except ImportError: print('dotenv not installed, skipping...') DOCKER = os.getenv('DOCKER', 'False').lower() == 'true' +USE_SLIM = os.getenv('USE_SLIM_DOCKER', 'False').lower() == 'true' USE_CUDA = os.getenv('USE_CUDA_DOCKER', 'false') DEVICE_TYPE = 'cpu' _cuda_error: Optional[str] = None -if USE_CUDA.lower() == 'true': +if not USE_SLIM and USE_CUDA.lower() == 'true': try: import torch # noqa: E402 @@ -59,7 +60,7 @@ if USE_CUDA.lower() == 'true': os.environ['USE_CUDA_DOCKER'] = 'false' USE_CUDA = 'false' -if sys.platform == 'darwin' and DEVICE_TYPE == 'cpu': +if not USE_SLIM and sys.platform == 'darwin' and DEVICE_TYPE == 'cpu': try: import torch # noqa: E402 @@ -844,7 +845,7 @@ MINERU_MAX_MARKDOWN_BYTES = ( # When enabled, skips pydub-based preprocessing (format conversion, compression, # and chunked splitting) before sending files to processing engines. Useful when # the upstream provider handles these steps or when ffmpeg is unavailable. -BYPASS_PYDUB_PREPROCESSING = os.getenv('BYPASS_PYDUB_PREPROCESSING', 'False').lower() == 'true' +BYPASS_PYDUB_PREPROCESSING = USE_SLIM or os.getenv('BYPASS_PYDUB_PREPROCESSING', 'False').lower() == 'true' # When disabled (default), the OpenAI catch-all proxy endpoint (/{path:path}) # is blocked. Enable only if you need direct passthrough to upstream OpenAI- diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index a3bdf4f327..ec7827be10 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -75,6 +75,7 @@ from open_webui.config import ( ) from open_webui.constants import ERROR_MESSAGES, TASKS from open_webui.env import ( + USE_SLIM, AIOHTTP_CLIENT_SESSION_SSL, AUDIT_EXCLUDED_PATHS, AUDIT_INCLUDED_PATHS, @@ -2297,6 +2298,7 @@ async def get_app_config(request: Request): 'auto_redirect': config.get('oauth.auto_redirect'), }, 'features': { + 'slim': USE_SLIM, # --- Public: required by login/signup page pre-auth --- 'auth': WEBUI_AUTH, 'auth_trusted_header': bool(WEBUI_AUTH_TRUSTED_EMAIL_HEADER), diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index bc50269f3d..a2d8b366db 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -7,6 +7,7 @@ import zipfile import ftfy import requests +from fastapi import HTTPException from azure.identity import DefaultAzureCredential from langchain_community.document_loaders import ( AzureAIDocumentIntelligenceLoader, @@ -20,6 +21,7 @@ from langchain_core.documents import Document from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, GLOBAL_LOG_LEVEL, + USE_SLIM, MINERU_MAX_MARKDOWN_BYTES, REQUESTS_VERIFY, ) @@ -671,6 +673,19 @@ class Loader: file_path=file_path, ) else: + if USE_SLIM: + if file_ext == 'csv': + return CSVLoaderWithSummary(file_path, filename, self._detect_text_encoding(file_path)) + if file_ext in ['htm', 'html']: + return BSHTMLLoader(file_path, open_encoding=self._detect_text_encoding(file_path)) + if file_ext in ['txt', 'md', 'markdown', 'rst', 'xml'] or self._is_text_file( + file_ext, file_content_type + ): + return TextLoader(file_path, encoding=self._detect_text_encoding(file_path)) + raise HTTPException( + 503, + 'This file type requires an external document extractor in slim. Configure one that supports it.', + ) if file_ext == 'pdf': loader = PyPDFLoader( file_path, diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index ec7c73770e..d064e8575e 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -10,8 +10,9 @@ from typing import Awaitable, Optional, Union from urllib.parse import quote import aiohttp +import numpy as np import requests -from huggingface_hub import snapshot_download +from fastapi import HTTPException from langchain_classic.retrievers import ( ContextualCompressionRetriever, EnsembleRetriever, @@ -34,6 +35,7 @@ from open_webui.env import ( ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS, MPS_INFERENCE_LOCK, OFFLINE_MODE, + USE_SLIM, ) from open_webui.models.access_grants import AccessGrants from open_webui.models.chats import Chats @@ -46,7 +48,7 @@ from open_webui.models.users import UserModel from open_webui.retrieval.loaders.youtube import YoutubeLoader from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.retrieval.external import retrieve_external_knowledge -from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.factory import get_vector_db_client from open_webui.retrieval.vector.main import GetResult, SearchResult from open_webui.retrieval.web.utils import get_web_loader from open_webui.utils.access_control.files import get_owner_accessible_folder_files, has_access_to_file @@ -333,7 +335,7 @@ class VectorSearchRetriever(BaseRetriever): def query_doc(collection_name: str, query_embedding: list[float], k: int, user: UserModel = None): try: log.debug('query_doc:doc %s', collection_name) - result = VECTOR_DB_CLIENT.search( + result = get_vector_db_client().search( collection_name=collection_name, vectors=[query_embedding], limit=k, @@ -351,7 +353,7 @@ def query_doc(collection_name: str, query_embedding: list[float], k: int, user: def get_doc(collection_name: str, user: UserModel = None): try: log.debug('get_doc:doc %s', collection_name) - result = VECTOR_DB_CLIENT.get(collection_name=collection_name) + result = get_vector_db_client().get(collection_name=collection_name) if result: log.info('query_doc:result %s %s', result.ids, result.metadatas) @@ -1110,6 +1112,8 @@ def get_embedding_function( if embedding_engine == '': # Sentence transformers: CPU-bound sync operation async def async_embedding_function(query, prefix=None, user=None): + if USE_SLIM: + raise HTTPException(503, 'Configure an external embedding engine (openai, ollama, azure_openai).') # Deferred so a missing local model degrades RAG instead of crashing boot. if embedding_function is None: raise ValueError( @@ -1243,6 +1247,14 @@ async def generate_embeddings( def get_reranking_function(reranking_engine, reranking_model, reranking_function, reranking_batch_size=32): + if USE_SLIM and reranking_model and reranking_engine != 'external': + + def unavailable(query, documents, user=None): + raise HTTPException( + 503, 'Configure an external reranker, or clear the reranking model to use cosine scoring.' + ) + + return unavailable if reranking_function is None: return None if reranking_engine == 'external': @@ -1688,6 +1700,8 @@ async def get_sources_from_items( def get_model_path(model: str, update_model: bool = False): + from huggingface_hub import snapshot_download + # Construct huggingface_hub kwargs with local_files_only to return the snapshot path cache_dir = os.getenv('SENTENCE_TRANSFORMERS_HOME') @@ -1733,6 +1747,17 @@ from langchain_core.callbacks import Callbacks from langchain_core.documents import BaseDocumentCompressor, Document +def cosine_similarity(query, documents) -> np.ndarray: + """Score one query against documents without loading a model runtime.""" + if len(documents) == 0: + return np.array([], dtype=float) + query = np.asarray(query, dtype=float).reshape(-1) + documents = np.asarray(documents, dtype=float) + query = query / max(np.linalg.norm(query), 1e-12) + documents = documents / np.maximum(np.linalg.norm(documents, axis=1, keepdims=True), 1e-12) + return documents @ query + + class RerankCompressor(BaseDocumentCompressor): embedding_function: Any top_n: int @@ -1768,18 +1793,18 @@ class RerankCompressor(BaseDocumentCompressor): query: str, callbacks: Callbacks | None = None, ) -> Sequence[Document]: + if not documents: + return [] reranking = self.reranking_function is not None scores = None if reranking: scores = await asyncio.to_thread(self.reranking_function, query, documents) else: - from sentence_transformers import util as st_util - query_embedding = await self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX) doc_texts = [doc.page_content for doc in documents] document_embedding = await self.embedding_function(doc_texts, RAG_EMBEDDING_CONTENT_PREFIX) - scores = st_util.cos_sim(query_embedding, document_embedding)[0] + scores = cosine_similarity(query_embedding, document_embedding) if scores is not None: docs_with_scores = list( diff --git a/backend/open_webui/retrieval/vector/async_client.py b/backend/open_webui/retrieval/vector/async_client.py index 481f26e19f..af8df23b87 100644 --- a/backend/open_webui/retrieval/vector/async_client.py +++ b/backend/open_webui/retrieval/vector/async_client.py @@ -15,9 +15,8 @@ transparently dispatches each call to a worker thread via `asyncio.to_thread`. Async callers can `await ASYNC_VECTOR_DB_CLIENT.x(...)` in place of `VECTOR_DB_CLIENT.x(...)` and the loop stays responsive. -The original `VECTOR_DB_CLIENT` is unchanged, so callers already running -inside `run_in_threadpool` (e.g. `save_docs_to_vector_db`) are not -affected. +Client initialization and calls run in the worker thread. Synchronous callers +already inside `run_in_threadpool` use `get_vector_db_client()` directly. Thread-safety expectations -------------------------- @@ -55,7 +54,7 @@ from __future__ import annotations import asyncio from typing import Dict, List, Optional, Union -from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.factory import get_vector_db_client from open_webui.retrieval.vector.main import ( GetResult, SearchResult, @@ -73,30 +72,30 @@ class AsyncVectorDBClient: typically swallowed by surrounding ``try/except``). """ - def __init__(self, sync_client: VectorDBBase) -> None: + def __init__(self, sync_client: Optional[VectorDBBase] = None) -> None: self._sync = sync_client @property def sync(self) -> VectorDBBase: """Escape hatch for code that must call the sync client directly (e.g. already inside a worker thread).""" - return self._sync + return self._sync if self._sync is not None else get_vector_db_client() @property def supports_hybrid_search(self) -> bool: - return type(self._sync).hybrid_search is not VectorDBBase.hybrid_search + return type(self.sync).hybrid_search is not VectorDBBase.hybrid_search async def has_collection(self, collection_name: str) -> bool: - return await asyncio.to_thread(self._sync.has_collection, collection_name) + return await asyncio.to_thread(lambda: self.sync.has_collection(collection_name)) async def delete_collection(self, collection_name: str) -> None: - return await asyncio.to_thread(self._sync.delete_collection, collection_name) + return await asyncio.to_thread(lambda: self.sync.delete_collection(collection_name)) async def insert(self, collection_name: str, items: List[VectorItem]) -> None: - return await asyncio.to_thread(self._sync.insert, collection_name, items) + return await asyncio.to_thread(lambda: self.sync.insert(collection_name, items)) async def upsert(self, collection_name: str, items: List[VectorItem]) -> None: - return await asyncio.to_thread(self._sync.upsert, collection_name, items) + return await asyncio.to_thread(lambda: self.sync.upsert(collection_name, items)) async def search( self, @@ -105,7 +104,7 @@ class AsyncVectorDBClient: filter: Optional[Dict] = None, limit: int = 10, ) -> Optional[SearchResult]: - return await asyncio.to_thread(self._sync.search, collection_name, vectors, filter, limit) + return await asyncio.to_thread(lambda: self.sync.search(collection_name, vectors, filter, limit)) async def hybrid_search( self, @@ -117,13 +116,7 @@ class AsyncVectorDBClient: hybrid_bm25_weight: float = 0.5, ) -> Optional[SearchResult]: return await asyncio.to_thread( - self._sync.hybrid_search, - collection_name, - query, - vectors, - filter, - limit, - hybrid_bm25_weight, + lambda: self.sync.hybrid_search(collection_name, query, vectors, filter, limit, hybrid_bm25_weight) ) async def query( @@ -132,10 +125,10 @@ class AsyncVectorDBClient: filter: Dict, limit: Optional[int] = None, ) -> Optional[GetResult]: - return await asyncio.to_thread(self._sync.query, collection_name, filter, limit) + return await asyncio.to_thread(lambda: self.sync.query(collection_name, filter, limit)) async def get(self, collection_name: str) -> Optional[GetResult]: - return await asyncio.to_thread(self._sync.get, collection_name) + return await asyncio.to_thread(lambda: self.sync.get(collection_name)) async def delete( self, @@ -143,10 +136,10 @@ class AsyncVectorDBClient: ids: Optional[List[str]] = None, filter: Optional[Dict] = None, ) -> None: - return await asyncio.to_thread(self._sync.delete, collection_name, ids, filter) + return await asyncio.to_thread(lambda: self.sync.delete(collection_name, ids, filter)) async def reset(self) -> None: - return await asyncio.to_thread(self._sync.reset) + return await asyncio.to_thread(lambda: self.sync.reset()) -ASYNC_VECTOR_DB_CLIENT = AsyncVectorDBClient(VECTOR_DB_CLIENT) +ASYNC_VECTOR_DB_CLIENT = AsyncVectorDBClient() diff --git a/backend/open_webui/retrieval/vector/dbs/chroma.py b/backend/open_webui/retrieval/vector/dbs/chroma.py index f6629164ff..2c51aeca93 100755 --- a/backend/open_webui/retrieval/vector/dbs/chroma.py +++ b/backend/open_webui/retrieval/vector/dbs/chroma.py @@ -16,6 +16,8 @@ from open_webui.config import ( CHROMA_HTTP_SSL, CHROMA_TENANT, ) +from open_webui.env import USE_SLIM +from fastapi import HTTPException from open_webui.retrieval.vector.main import ( GetResult, SearchResult, @@ -29,6 +31,8 @@ log = logging.getLogger(__name__) class ChromaClient(VectorDBBase): def __init__(self): + if USE_SLIM and not CHROMA_HTTP_HOST: + raise HTTPException(503, 'Configure CHROMA_HTTP_HOST: embedded Chroma is unavailable in slim.') settings_dict = { 'allow_reset': True, 'anonymized_telemetry': False, @@ -58,7 +62,7 @@ class ChromaClient(VectorDBBase): def has_collection(self, collection_name: str) -> bool: try: - self.client.get_collection(name=collection_name) + self.client.get_collection(name=collection_name, embedding_function=None) return True except NotFoundError: return False @@ -76,7 +80,7 @@ class ChromaClient(VectorDBBase): ) -> Optional[SearchResult]: # Search for the nearest neighbor items based on the vectors and return 'limit' number of results. try: - collection = self.client.get_collection(name=collection_name) + collection = self.client.get_collection(name=collection_name, embedding_function=None) if collection: result = collection.query( query_embeddings=vectors, @@ -105,7 +109,7 @@ class ChromaClient(VectorDBBase): def query(self, collection_name: str, filter: dict, limit: Optional[int] = None) -> Optional[GetResult]: # Query the items from the collection based on the filter. try: - collection = self.client.get_collection(name=collection_name) + collection = self.client.get_collection(name=collection_name, embedding_function=None) if collection: result = collection.get( where=filter, @@ -125,7 +129,7 @@ class ChromaClient(VectorDBBase): def get(self, collection_name: str) -> Optional[GetResult]: # Get all the items in the collection. - collection = self.client.get_collection(name=collection_name) + collection = self.client.get_collection(name=collection_name, embedding_function=None) if collection: result = collection.get() return GetResult( @@ -139,7 +143,9 @@ class ChromaClient(VectorDBBase): def insert(self, collection_name: str, items: list[VectorItem]): # Insert the items into the collection, if the collection does not exist, it will be created. - collection = self.client.get_or_create_collection(name=collection_name, metadata={'hnsw:space': 'cosine'}) + collection = self.client.get_or_create_collection( + name=collection_name, metadata={'hnsw:space': 'cosine'}, embedding_function=None + ) ids = [item['id'] for item in items] documents = [item['text'] for item in items] @@ -157,7 +163,9 @@ class ChromaClient(VectorDBBase): def upsert(self, collection_name: str, items: list[VectorItem]): # Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created. - collection = self.client.get_or_create_collection(name=collection_name, metadata={'hnsw:space': 'cosine'}) + collection = self.client.get_or_create_collection( + name=collection_name, metadata={'hnsw:space': 'cosine'}, embedding_function=None + ) ids = [item['id'] for item in items] documents = [item['text'] for item in items] @@ -174,7 +182,7 @@ class ChromaClient(VectorDBBase): ): # Delete the items from the collection based on the ids. try: - collection = self.client.get_collection(name=collection_name) + collection = self.client.get_collection(name=collection_name, embedding_function=None) if collection: if ids: collection.delete(ids=ids) diff --git a/backend/open_webui/retrieval/vector/factory.py b/backend/open_webui/retrieval/vector/factory.py index 3080956163..8173bc1b6f 100644 --- a/backend/open_webui/retrieval/vector/factory.py +++ b/backend/open_webui/retrieval/vector/factory.py @@ -1,8 +1,12 @@ +from threading import Lock + +from fastapi import HTTPException from open_webui.config import ( ENABLE_MILVUS_MULTITENANCY_MODE, ENABLE_QDRANT_MULTITENANCY_MODE, VECTOR_DB, ) +from open_webui.env import USE_SLIM from open_webui.retrieval.vector.main import VectorDBBase from open_webui.retrieval.vector.type import VectorType @@ -88,4 +92,35 @@ class Vector: raise ValueError(f'Unsupported vector type: {vector_type}') -VECTOR_DB_CLIENT = Vector.get_vector(VECTOR_DB) +VECTOR_DB_CLIENT = None if USE_SLIM else Vector.get_vector(VECTOR_DB) +_vector_client_lock = Lock() + + +def get_vector_db_client() -> VectorDBBase: + """Initialize slim's remote client on first use so chat can start without it.""" + global VECTOR_DB_CLIENT + if VECTOR_DB_CLIENT is not None: + return VECTOR_DB_CLIENT + with _vector_client_lock: + if VECTOR_DB_CLIENT is None: + from open_webui import config + + if VECTOR_DB == VectorType.CHROMA and not config.CHROMA_HTTP_HOST: + raise HTTPException( + 503, 'Slim requires remote vector storage. Set CHROMA_HTTP_HOST or configure another VECTOR_DB.' + ) + if VECTOR_DB == VectorType.MILVUS and not config.MILVUS_URI.startswith(('http://', 'https://', 'tcp://')): + raise HTTPException(503, 'Slim requires an external MILVUS_URI, not a local database file.') + if VECTOR_DB == VectorType.QDRANT and not config.QDRANT_URI: + raise HTTPException(503, 'Configure QDRANT_URI for remote vector storage.') + if VECTOR_DB == VectorType.PGVECTOR and not config.PGVECTOR_DB_URL.startswith('postgres'): + raise HTTPException(503, 'Configure PGVECTOR_DB_URL for remote vector storage.') + try: + VECTOR_DB_CLIENT = Vector.get_vector(VECTOR_DB) + except HTTPException: + raise + except Exception as exc: + raise HTTPException( + 503, f'Unable to connect to configured vector database ({VECTOR_DB}): {exc}' + ) from exc + return VECTOR_DB_CLIENT diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index a6099afe1d..25ec988279 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -29,7 +29,9 @@ import urllib3.connection import urllib3.connectionpool import validators from requests.adapters import HTTPAdapter +from fastapi import HTTPException from fastapi.concurrency import run_in_threadpool +from bs4 import BeautifulSoup from langchain_community.document_loaders import PlaywrightURLLoader, WebBaseLoader from langchain_community.document_loaders.base import BaseLoader from langchain_core.documents import Document @@ -58,6 +60,7 @@ from open_webui.env import ( AIOHTTP_CLIENT_SSL_CERT_FILE, AIOHTTP_CLIENT_TIMEOUT, USER_AGENT, + USE_SLIM, ) from open_webui.retrieval.loaders.external_web import ExternalWebLoader from open_webui.retrieval.loaders.microsoft_web_iq import MicrosoftWebIQLoader @@ -643,6 +646,26 @@ class SafeMicrosoftWebIQLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): raise e +class TextHtmlEvaluator: + """Extract rendered text without Unstructured or its local NLP dependencies.""" + + def __init__(self, remove_selectors=None): + self.remove_selectors = remove_selectors or [] + + def text(self, html): + soup = BeautifulSoup(html, 'lxml') + for selector in ['script', 'style', 'noscript', *self.remove_selectors]: + for element in soup.select(selector): + element.decompose() + return soup.get_text(separator='\n', strip=True) + + def evaluate(self, page, browser, response): + return self.text(page.content()) + + async def evaluate_async(self, page, browser, response): + return self.text(await page.content()) + + class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessingMixin): """Load HTML pages safely with Playwright, supporting SSL verification, rate limiting, and remote browser connection. @@ -674,6 +697,8 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing playwright_timeout: Optional[int] = 10000, ): """Initialize with additional safety parameters and remote browser support.""" + if USE_SLIM and not playwright_ws_url: + raise HTTPException(503, 'Configure PLAYWRIGHT_WS_URL. Slim requires a remote browser.') proxy_server = proxy.get('server') if proxy else None if trust_env and not proxy_server: @@ -690,7 +715,8 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing urls=web_paths, continue_on_failure=continue_on_failure, headless=headless if playwright_ws_url is None else False, - remove_selectors=remove_selectors, + remove_selectors=None if USE_SLIM else remove_selectors, + evaluator=TextHtmlEvaluator(remove_selectors) if USE_SLIM else None, proxy=proxy, ) self.verify_ssl = verify_ssl diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 9fa5d89304..dfc0ecde65 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -46,6 +46,7 @@ from open_webui.env import ( DEVICE_TYPE, ENABLE_FORWARD_USER_INFO_HEADERS, ENV, + USE_SLIM, ) from open_webui.events import EVENTS, publish_event from open_webui.models.config import Config @@ -58,9 +59,10 @@ from open_webui.utils.session_pool import get_session from pydantic import BaseModel # pydub needs stdlib audioop (gone in 3.13); keep requires-python capped < 3.13 -from pydub import AudioSegment -from pydub.silence import split_on_silence -from pydub.utils import mediainfo +if not USE_SLIM: + from pydub import AudioSegment + from pydub.silence import split_on_silence + from pydub.utils import mediainfo log = logging.getLogger(__name__) router = APIRouter() @@ -213,6 +215,8 @@ def transcode_audio_to_mp3(audio_data: bytes, content_type_header: str, output_p def set_faster_whisper_model(model: str, auto_update: bool = False): + if USE_SLIM: + raise HTTPException(503, 'Configure an external speech-to-text engine. Local Whisper is unavailable in slim.') whisper_model = None if model: from faster_whisper import WhisperModel @@ -285,6 +289,12 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)): @router.post('/config/update') async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm, user=Depends(get_admin_user)): + if USE_SLIM: + current = await Config.get_many('audio.stt.engine', 'audio.tts.engine') + if form_data.stt.ENGINE == '' and current.get('audio.stt.engine') != '': + raise HTTPException(400, 'Local Whisper is unavailable in slim. Select an external speech-to-text engine.') + if form_data.tts.ENGINE == 'transformers' and current.get('audio.tts.engine') != 'transformers': + raise HTTPException(400, 'Local TTS is unavailable in slim. Select an external text-to-speech engine.') await Config.upsert( { **config_updates(form_data.tts.model_dump(), TTS_CONFIG_KEYS), @@ -292,7 +302,7 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm } ) - if form_data.stt.ENGINE == '': + if form_data.stt.ENGINE == '' and not USE_SLIM: request.app.state.faster_whisper_model = await asyncio.to_thread( set_faster_whisper_model, form_data.stt.WHISPER_MODEL, WHISPER_MODEL_AUTO_UPDATE ) @@ -314,6 +324,8 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm def load_speech_pipeline(request): + if USE_SLIM: + raise HTTPException(503, 'Configure an external text-to-speech engine. Local TTS is unavailable in slim.') from datasets import load_dataset from transformers import pipeline @@ -328,7 +340,9 @@ def load_speech_pipeline(request): async def _raise_tts_error(exc: Exception, r=None) -> None: """Raise a standardised HTTPException from a TTS provider failure.""" - code = r.status if r is not None else 500 + if isinstance(exc, HTTPException): + raise exc + code = r.status if r is not None and r.status >= 400 else 500 # LICENSE covers this Open WebUI error identifier. # Do not alter, remove, obscure, or replace it except as LICENSE permits: # https://docs.openwebui.com/license. @@ -351,8 +365,29 @@ async def _write_tts_cache( audio: bytes, body_path: Path, payload: dict, + content_type: str = 'audio/mpeg', ) -> None: """Persist audio + request metadata to the speech cache.""" + if USE_SLIM: + mime_type = content_type.split(';')[0].strip().lower() + if mime_type not in { + 'audio/mpeg', + 'audio/mp3', + 'audio/wav', + 'audio/x-wav', + 'audio/ogg', + 'audio/opus', + 'audio/webm', + 'audio/flac', + 'audio/aac', + 'audio/mp4', + }: + raise HTTPException( + 502, + f'TTS returned unsupported format {mime_type}. Configure the provider to return MP3, WAV, Ogg, or another browser-playable audio format.', + ) + async with aiofiles.open(file_path.with_suffix('.mime'), 'w') as f: + await f.write(content_type) async with aiofiles.open(file_path, 'wb') as f: await f.write(audio) async with aiofiles.open(body_path, 'w') as f: @@ -389,6 +424,10 @@ async def _tts_openai(request, payload, file_path, file_body_path, user): audio_data = await r.read() content_type = r.headers.get('Content-Type', 'audio/mpeg') + if USE_SLIM: + await _write_tts_cache(file_path, audio_data, file_body_path, payload, content_type) + return FileResponse(file_path, media_type=content_type) + if not await asyncio.to_thread(transcode_audio_to_mp3, audio_data, content_type, file_path): async with aiofiles.open(file_path, 'wb') as f: await f.write(audio_data) @@ -430,8 +469,9 @@ async def _tts_elevenlabs(request, payload, file_path, file_body_path, user): ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: r.raise_for_status() - await _write_tts_cache(file_path, await r.read(), file_body_path, payload) - return FileResponse(file_path) + content_type = r.headers.get('Content-Type', 'audio/mpeg') + await _write_tts_cache(file_path, await r.read(), file_body_path, payload, content_type) + return FileResponse(file_path, media_type=content_type if USE_SLIM else None) except Exception as exc: log.exception(exc) await _raise_tts_error(exc, r) @@ -465,8 +505,9 @@ async def _tts_azure(request, payload, file_path, file_body_path, user): ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: r.raise_for_status() - await _write_tts_cache(file_path, await r.read(), file_body_path, payload) - return FileResponse(file_path) + content_type = r.headers.get('Content-Type', 'audio/mpeg') + await _write_tts_cache(file_path, await r.read(), file_body_path, payload, content_type) + return FileResponse(file_path, media_type=content_type if USE_SLIM else None) except Exception as exc: log.exception(exc) await _raise_tts_error(exc, r) @@ -474,6 +515,8 @@ async def _tts_azure(request, payload, file_path, file_body_path, user): async def _tts_transformers(request, payload, file_path, file_body_path, user): """Generate speech via the local HuggingFace SpeechT5 pipeline (thread-offloaded).""" + if USE_SLIM: + raise HTTPException(503, 'Configure an external text-to-speech engine. Local TTS is unavailable in slim.') import soundfile as sf import torch @@ -558,6 +601,8 @@ _TTS_ENGINES = { @router.post('/speech') async def speech(request: Request, user=Depends(get_verified_user)): engine = await Config.get('audio.tts.engine') + if USE_SLIM and engine in ('', 'transformers'): + raise HTTPException(503, 'Configure an external text-to-speech engine.') if engine == '': raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -572,7 +617,10 @@ async def speech(request: Request, user=Depends(get_verified_user)): body = await request.body() name = hashlib.sha256( - body + str(engine).encode('utf-8') + str(await Config.get('audio.tts.model')).encode('utf-8') + body + + str(engine).encode('utf-8') + + str(await Config.get('audio.tts.model')).encode('utf-8') + + (b':slim' if USE_SLIM else b'') ).hexdigest() file_path = SPEECH_CACHE_DIR.joinpath(f'{name}.mp3') @@ -587,7 +635,11 @@ async def speech(request: Request, user=Depends(get_verified_user)): subject_id=name, data={'engine': engine, 'cached': True}, ) - return FileResponse(file_path) + content_type = None + if USE_SLIM: + async with aiofiles.open(file_path.with_suffix('.mime')) as f: + content_type = await f.read() + return FileResponse(file_path, media_type=content_type) try: payload = JSONCodec.loads(body) @@ -960,7 +1012,11 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir, session = await get_session() if use_chat_completions: audio_file_to_use = file_path - if is_audio_conversion_required(file_path): + if USE_SLIM and Path(filename).suffix.lower() not in ('.mp3', '.wav'): + raise HTTPException( + 400, 'Mistral chat transcription requires MP3 or WAV in slim; local conversion is unavailable.' + ) + if not BYPASS_PYDUB_PREPROCESSING and is_audio_conversion_required(file_path): log.debug('Converting audio to mp3 for chat completions API') converted_path = await asyncio.to_thread(convert_audio_to_mp3, file_path) if converted_path: diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index e1c7543eea..00c1517c88 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -6,6 +6,8 @@ from fastapi.concurrency import run_in_threadpool from open_webui.constants import ERROR_MESSAGES from open_webui.env import MPS_INFERENCE_LOCK from open_webui.events import EVENTS, publish_event +from open_webui.env import USE_SLIM +from open_webui.retrieval.utils import cosine_similarity from open_webui.internal.db import get_async_session from open_webui.models.config import Config from open_webui.models.feedbacks import ( @@ -69,6 +71,8 @@ _embedding_model = None def _get_embedding_model(): global _embedding_model + if USE_SLIM: + return None if _embedding_model is None: try: from sentence_transformers import SentenceTransformer @@ -217,6 +221,7 @@ class LeaderboardResponse(BaseModel): @router.get('/leaderboard', response_model=LeaderboardResponse) async def get_leaderboard( + request: Request, query: Optional[str] = None, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session), @@ -226,7 +231,17 @@ async def get_leaderboard( similarities = None if query and query.strip(): - similarities = await run_in_threadpool(_compute_similarities, feedbacks, query.strip()) + if USE_SLIM: + tags = list({tag for feedback in feedbacks for tag in (feedback.data or {}).get('tags', [])}) + embeddings = await request.app.state.EMBEDDING_FUNCTION([query.strip(), *tags], user=user) + scores = cosine_similarity(embeddings[0], embeddings[1:]) + tag_scores = dict(zip(tags, scores.tolist())) + similarities = { + feedback.id: max((tag_scores.get(tag, 0) for tag in (feedback.data or {}).get('tags', [])), default=0) + for feedback in feedbacks + } + else: + similarities = await run_in_threadpool(_compute_similarities, feedbacks, query.strip()) elo_stats = _calculate_elo(feedbacks, similarities) tags_by_model = _get_top_tags(feedbacks) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 9437649d4d..824743fd5d 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -58,6 +58,7 @@ from open_webui.env import ( SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS, SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION, SENTENCE_TRANSFORMERS_MODEL_KWARGS, + USE_SLIM, ) from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_db, get_async_session @@ -82,7 +83,7 @@ from open_webui.retrieval.utils import ( query_doc_with_hybrid_search, ) from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT -from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.factory import get_vector_db_client from open_webui.retrieval.vector.utils import filter_metadata from open_webui.retrieval.web.azure import search_azure from open_webui.retrieval.web.bing import search_bing @@ -150,7 +151,7 @@ def get_ef( auto_update: bool = RAG_EMBEDDING_MODEL_AUTO_UPDATE, ): ef = None - if embedding_model and engine == '': + if embedding_model and engine == '' and not USE_SLIM: from sentence_transformers import SentenceTransformer try: @@ -178,6 +179,17 @@ def get_rf( rf = None # Convert timeout string to int or None (system default) timeout_value = int(external_reranker_timeout) if external_reranker_timeout else None + if reranking_model and engine == 'external': + from open_webui.retrieval.models.external import ExternalReranker + + return ExternalReranker( + url=external_reranker_url, + api_key=external_reranker_api_key, + model=reranking_model, + timeout=timeout_value, + ) + if USE_SLIM: + return None if reranking_model: if any(model in reranking_model for model in ['jinaai/jina-colbert-v2']): try: @@ -192,55 +204,39 @@ def get_rf( log.error(f'ColBERT: {e}') raise Exception(ERROR_MESSAGES.DEFAULT(e, 'Error loading reranking model')) else: - if engine == 'external': - try: - from open_webui.retrieval.models.external import ExternalReranker + import sentence_transformers + import torch - rf = ExternalReranker( - url=external_reranker_url, - api_key=external_reranker_api_key, - model=reranking_model, - timeout=timeout_value, - ) - except Exception as e: - log.error(f'ExternalReranking: {e}') - raise Exception(ERROR_MESSAGES.DEFAULT(e, 'Error loading reranking model')) - else: - import sentence_transformers - import torch + try: + rf = sentence_transformers.CrossEncoder( + get_model_path(reranking_model, auto_update), + device=DEVICE_TYPE, + trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, + backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND, + model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS, + activation_fn=( + torch.nn.Sigmoid() if SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION else None + ), + ) + except Exception as e: + log.error(f'CrossEncoder: {e}') + raise Exception(ERROR_MESSAGES.DEFAULT(e, 'CrossEncoder error')) - try: - rf = sentence_transformers.CrossEncoder( - get_model_path(reranking_model, auto_update), - device=DEVICE_TYPE, - trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, - backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND, - model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS, - activation_fn=( - torch.nn.Sigmoid() - if SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION - else None - ), - ) - except Exception as e: - log.error(f'CrossEncoder: {e}') - raise Exception(ERROR_MESSAGES.DEFAULT(e, 'CrossEncoder error')) - - # Safely adjust pad_token_id if missing as some models do not have this in config - try: - model_cfg = getattr(rf, 'model', None) - if model_cfg and hasattr(model_cfg, 'config'): - cfg = model_cfg.config - if getattr(cfg, 'pad_token_id', None) is None: - # Fallback to eos_token_id when available - eos = getattr(cfg, 'eos_token_id', None) - if eos is not None: - cfg.pad_token_id = eos - log.debug('Missing pad_token_id detected; set to eos_token_id=%s', eos) - else: - log.warning('Neither pad_token_id nor eos_token_id present in model config') - except Exception as e2: - log.warning(f'Failed to adjust pad_token_id on CrossEncoder: {e2}') + # Safely adjust pad_token_id if missing as some models do not have this in config + try: + model_cfg = getattr(rf, 'model', None) + if model_cfg and hasattr(model_cfg, 'config'): + cfg = model_cfg.config + if getattr(cfg, 'pad_token_id', None) is None: + # Fallback to eos_token_id when available + eos = getattr(cfg, 'eos_token_id', None) + if eos is not None: + cfg.pad_token_id = eos + log.debug('Missing pad_token_id detected; set to eos_token_id=%s', eos) + else: + log.warning('Neither pad_token_id nor eos_token_id present in model config') + except Exception as e2: + log.warning(f'Failed to adjust pad_token_id on CrossEncoder: {e2}') return rf @@ -535,6 +531,8 @@ async def unload_embedding_model(request: Request): @router.post('/embedding/update') async def update_embedding_config(request: Request, form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user)): + if USE_SLIM and form_data.RAG_EMBEDDING_ENGINE == '': + raise HTTPException(400, 'Slim requires an external embedding engine (openai, ollama, azure_openai).') config = await get_retrieval_config() log.info('Updating embedding model: %s to %s', config.RAG_EMBEDDING_MODEL, form_data.RAG_EMBEDDING_MODEL) await unload_embedding_model(request) @@ -952,6 +950,44 @@ class ConfigForm(BaseModel): async def update_rag_config(request: Request, form_data: ConfigForm, user=Depends(get_admin_user)): # RAG settings config = await get_retrieval_config() + if USE_SLIM: + if form_data.web: + web_engine = ( + form_data.web.WEB_LOADER_ENGINE + if form_data.web.WEB_LOADER_ENGINE is not None + else config.WEB_LOADER_ENGINE + ) + browser_url = ( + form_data.web.PLAYWRIGHT_WS_URL + if form_data.web.PLAYWRIGHT_WS_URL is not None + else config.PLAYWRIGHT_WS_URL + ) + if ( + web_engine == 'playwright' + and not browser_url + and (web_engine != config.WEB_LOADER_ENGINE or browser_url != config.PLAYWRIGHT_WS_URL) + ): + raise HTTPException(400, 'Configure PLAYWRIGHT_WS_URL. Slim requires a remote browser.') + if form_data.TEXT_SPLITTER == 'token_transformers' and config.TEXT_SPLITTER != 'token_transformers': + raise HTTPException( + 400, 'Transformers tokenization is unavailable in slim. Use character or token splitting.' + ) + reranker_engine = ( + form_data.RAG_RERANKING_ENGINE + if form_data.RAG_RERANKING_ENGINE is not None + else config.RAG_RERANKING_ENGINE + ) + reranker_model = ( + form_data.RAG_RERANKING_MODEL if form_data.RAG_RERANKING_MODEL is not None else config.RAG_RERANKING_MODEL + ) + if ( + reranker_engine != 'external' + and reranker_model + and (reranker_engine != config.RAG_RERANKING_ENGINE or reranker_model != config.RAG_RERANKING_MODEL) + ): + raise HTTPException( + 400, 'Slim requires an external reranker, or an empty reranking model for cosine scoring.' + ) config.RAG_TEMPLATE = form_data.RAG_TEMPLATE if form_data.RAG_TEMPLATE is not None else config.RAG_TEMPLATE config.TOP_K = form_data.TOP_K if form_data.TOP_K is not None else config.TOP_K config.BYPASS_EMBEDDING_AND_RETRIEVAL = ( @@ -1589,6 +1625,8 @@ def merge_docs_to_target_size( def get_transformers_tokenizer(request: Request, config: RetrievalConfig): + if USE_SLIM: + raise HTTPException(503, 'Transformers tokenization is unavailable in slim. Use character or token splitting.') if config.RAG_TOKENIZER_MODEL: from transformers import AutoTokenizer @@ -1673,7 +1711,7 @@ def save_docs_to_vector_db( # Check if entries with the same hash (metadata.hash) already exist if metadata and 'hash' in metadata: - result = VECTOR_DB_CLIENT.query( + result = get_vector_db_client().query( collection_name=collection_name, filter={'hash': metadata['hash']}, ) @@ -1773,11 +1811,11 @@ def save_docs_to_vector_db( ] try: - if VECTOR_DB_CLIENT.has_collection(collection_name=collection_name): + if get_vector_db_client().has_collection(collection_name=collection_name): log.info('collection %s already exists', collection_name) if overwrite: - VECTOR_DB_CLIENT.delete_collection(collection_name=collection_name) + get_vector_db_client().delete_collection(collection_name=collection_name) log.info('deleting existing collection %s', collection_name) elif add is False: log.info('collection %s already exists, overwrite is False and add is False', collection_name) @@ -1840,7 +1878,7 @@ def save_docs_to_vector_db( ] log.info('adding to collection %s', collection_name) - VECTOR_DB_CLIENT.insert( + get_vector_db_client().insert( collection_name=collection_name, items=items, ) @@ -3035,7 +3073,7 @@ async def query_doc_handler( query_embedding = await request.app.state.EMBEDDING_FUNCTION( form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user ) - # query_doc wraps a blocking VECTOR_DB_CLIENT.search call; + # query_doc wraps a blocking get_vector_db_client().search call; # offload so the request's event loop stays responsive. return await asyncio.to_thread( query_doc, diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt deleted file mode 100644 index 175aa9f904..0000000000 --- a/backend/requirements-min.txt +++ /dev/null @@ -1,54 +0,0 @@ -# Minimal requirements for backend to run -# WIP: use this as a reference to build a minimal docker image - -fastapi==0.136.3 -uvicorn[standard]==0.51.0 -pydantic==2.13.4 -python-multipart==0.0.32 -itsdangerous==2.2.0 - -python-socketio==5.16.2 -orjson==3.11.9 -cryptography -bcrypt==5.0.0 -argon2-cffi==25.1.0 -PyJWT[crypto]==2.13.0 -authlib==1.7.2 -joserfc==1.7.4 - -requests==2.34.2 -aiohttp==3.13.5 # do not update to 3.13.3 - broken -aiocache -aiofiles -starlette-compress==1.7.1 -Brotli==1.2.0 -brotlicffi==1.2.0.1 -httpx[socks,http2,zstd,cli,brotli]==0.28.1 -starsessions[redis]==2.2.1 - -sqlalchemy==2.0.50 -aiosqlite==0.22.1 -psycopg[binary]==3.3.4 -alembic==1.18.4 - -pycrdt==0.13.1 -redis -hiredis - -loguru==0.7.3 -asgiref==3.11.1 - -mcp==1.27.2 -openai - -langchain-community==0.4.2 -langchain-classic==1.0.7 -langchain-text-splitters==1.1.2 - -fake-useragent==2.2.0 - -chromadb==1.5.9 -black==26.5.1 -pydub -chardet==7.4.3 -beautifulsoup4 diff --git a/backend/requirements-slim.txt b/backend/requirements-slim.txt new file mode 100644 index 0000000000..e231eb0699 --- /dev/null +++ b/backend/requirements-slim.txt @@ -0,0 +1,122 @@ +# External-services image. Keep shared package pins aligned with requirements.txt. +# NumPy is used for cosine scoring; pandas is required by the remote Milvus client. +numpy==2.4.6 +typer==0.25.1 +fastapi==0.136.3 +uvicorn[standard]==0.51.0 +pydantic==2.13.4 +python-multipart==0.0.32 +itsdangerous==2.2.0 + +python-socketio==5.16.2 +orjson==3.11.9 +cryptography==48.0.0 +bcrypt==5.0.0 +argon2-cffi==25.1.0 +PyJWT[crypto]==2.13.0 +authlib==1.7.2 +joserfc==1.7.4 + +requests==2.34.2 +regex==2026.5.9 # supports a per-search timeout, which `re` does not +aiohttp==3.13.5 # do not update to 3.13.3 - broken +aiodns==3.6.1 # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER +aiocache==0.12.3 +aiofiles==25.1.0 +starlette-compress==1.7.1 +Brotli==1.2.0 +httpx[socks,http2,zstd,cli,brotli]==0.28.1 +starsessions[redis]==2.2.1 +python-mimeparse==2.0.0 + +sqlalchemy[asyncio]==2.0.50 +aiosqlite==0.22.1 +psycopg[binary]==3.3.4 +alembic==1.18.4 + +pycrdt==0.13.1 +redis==8.0.1 +hiredis==3.4.0 + +APScheduler==3.11.2 +pytz==2026.2 + +loguru==0.7.3 +asgiref==3.11.1 + +# AI libraries +tiktoken==0.13.0 +mcp==1.27.2 + +openai==2.29.0 + +langchain-community==0.4.2 +langchain-classic==1.0.7 +langchain-text-splitters==1.1.2 + +fake-useragent==2.2.0 +chromadb-client==1.5.9 +weaviate-client==4.20.3 +opensearch-py==3.2.0 + +ftfy==6.3.1 +chardet==7.4.3 +fpdf2==2.8.7 + +Markdown==3.10.2 +beautifulsoup4==4.14.3 +lxml==6.1.1 +pandas==3.0.3 +validators==0.35.0 +psutil==7.2.2 + +pillow==12.2.0 +rank-bm25==0.2.2 + +black==26.5.1 +youtube-transcript-api==1.2.4 + +ddgs==9.14.4 + +azure-ai-documentintelligence==1.0.2 +azure-identity==1.25.3 +azure-storage-blob==12.29.0 +azure-search-documents==12.0.0 + +## Google Cloud storage + +googleapis-common-protos==1.75.0 +google-cloud-storage==3.9.0 + +## Databases +psycopg2-binary==2.9.12 +pgvector==0.4.2 + +PyMySQL==1.2.0 +boto3==1.42.62 +# mariadb==1.1.14 should be added if you want to support MariaDB +# valkey-glide-sync==2.3.1 # optional: install manually if VECTOR_DB=valkey + +pymilvus==2.6.14 +qdrant-client==1.18.0 +playwright==1.60.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary +elasticsearch==9.4.1 +pinecone==6.0.2 +oracledb==3.4.2 + +## LDAP +ldap3==2.9.1 + +## Trace +opentelemetry-api==1.42.1 +opentelemetry-sdk==1.42.1 +opentelemetry-exporter-otlp==1.42.1 +opentelemetry-instrumentation==0.63b1 +opentelemetry-instrumentation-fastapi==0.63b1 +opentelemetry-instrumentation-sqlalchemy==0.63b1 +opentelemetry-instrumentation-redis==0.63b1 +opentelemetry-instrumentation-requests==0.63b1 +opentelemetry-instrumentation-logging==0.63b1 +opentelemetry-instrumentation-httpx==0.63b1 +opentelemetry-instrumentation-aiohttp-client==0.63b1 +opentelemetry-instrumentation-system-metrics==0.63b1 diff --git a/backend/start.sh b/backend/start.sh index b80e2fd67f..ef7cb5af10 100755 --- a/backend/start.sh +++ b/backend/start.sh @@ -11,14 +11,14 @@ set -euo pipefail # expansion. The two can't be combined inline (`${VAR:-default,,}` makes # the default literal `,,`), so we normalise once up front and the simple # `${VAR,,}` form stays safe under `set -u` everywhere else. -: "${WEB_LOADER_ENGINE:=}" "${USE_OLLAMA_DOCKER:=}" "${USE_CUDA_DOCKER:=}" +: "${USE_SLIM_DOCKER:=}" "${WEB_LOADER_ENGINE:=}" "${USE_OLLAMA_DOCKER:=}" "${USE_CUDA_DOCKER:=}" SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) cd "$SCRIPT_DIR" || exit 1 # ── Playwright browser installation (if configured) ────────────────────────── -if [[ "${WEB_LOADER_ENGINE,,}" == "playwright" ]]; then +if [[ "${USE_SLIM_DOCKER,,}" != "true" && "${WEB_LOADER_ENGINE,,}" == "playwright" ]]; then if [[ -z "${PLAYWRIGHT_WS_URL:-}" ]]; then echo "Installing Playwright Chromium browser..." playwright install chromium diff --git a/src/lib/components/admin/Settings/Audio.svelte b/src/lib/components/admin/Settings/Audio.svelte index e09e4aee3d..ae3d2b07ef 100644 --- a/src/lib/components/admin/Settings/Audio.svelte +++ b/src/lib/components/admin/Settings/Audio.svelte @@ -245,6 +245,13 @@ }} >

{$i18n.t('Audio')}

+ {#if $config?.features?.slim === true} +

+ {$i18n.t( + 'Slim requires external speech providers. Local speech models and audio conversion are unavailable.' + )} +

+ {/if}
@@ -253,7 +260,9 @@ description={$i18n.t('Choose the transcription provider used for audio input.')} > - + @@ -500,7 +509,9 @@ }} > - + diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 2c49b05520..c24e3a5459 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -1,4 +1,5 @@