diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 898ac1b594..1beed9f21b 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -19,6 +19,7 @@ from open_webui.env import ( DATABASE_URL, ENV, REDIS_URL, + REDIS_KEY_PREFIX, REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT, FRONTEND_BUILD_DIR, @@ -211,11 +212,16 @@ class PersistentConfig(Generic[T]): class AppConfig: _state: dict[str, PersistentConfig] _redis: Optional[redis.Redis] = None + _redis_key_prefix: str def __init__( - self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = [] + self, + redis_url: Optional[str] = None, + redis_sentinels: Optional[list] = [], + redis_key_prefix: str = "open-webui", ): super().__setattr__("_state", {}) + super().__setattr__("_redis_key_prefix", redis_key_prefix) if redis_url: super().__setattr__( "_redis", @@ -230,7 +236,7 @@ class AppConfig: self._state[key].save() if self._redis: - redis_key = f"open-webui:config:{key}" + redis_key = f"{self._redis_key_prefix}:config:{key}" self._redis.set(redis_key, json.dumps(self._state[key].value)) def __getattr__(self, key): @@ -239,7 +245,7 @@ class AppConfig: # If Redis is available, check for an updated value if self._redis: - redis_key = f"open-webui:config:{key}" + redis_key = f"{self._redis_key_prefix}:config:{key}" redis_value = self._redis.get(redis_key) if redis_value is not None: @@ -431,6 +437,12 @@ OAUTH_SCOPES = PersistentConfig( os.environ.get("OAUTH_SCOPES", "openid email profile"), ) +OAUTH_TIMEOUT = PersistentConfig( + "OAUTH_TIMEOUT", + "oauth.oidc.oauth_timeout", + os.environ.get("OAUTH_TIMEOUT", ""), +) + OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig( "OAUTH_CODE_CHALLENGE_METHOD", "oauth.oidc.code_challenge_method", @@ -540,7 +552,14 @@ def load_oauth_providers(): client_id=GOOGLE_CLIENT_ID.value, client_secret=GOOGLE_CLIENT_SECRET.value, server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", - client_kwargs={"scope": GOOGLE_OAUTH_SCOPE.value}, + client_kwargs={ + "scope": GOOGLE_OAUTH_SCOPE.value, + **( + {"timeout": int(OAUTH_TIMEOUT.value)} + if OAUTH_TIMEOUT.value + else {} + ), + }, redirect_uri=GOOGLE_REDIRECT_URI.value, ) @@ -563,6 +582,11 @@ def load_oauth_providers(): server_metadata_url=f"{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}", client_kwargs={ "scope": MICROSOFT_OAUTH_SCOPE.value, + **( + {"timeout": int(OAUTH_TIMEOUT.value)} + if OAUTH_TIMEOUT.value + else {} + ), }, redirect_uri=MICROSOFT_REDIRECT_URI.value, ) @@ -584,7 +608,14 @@ def load_oauth_providers(): authorize_url="https://github.com/login/oauth/authorize", api_base_url="https://api.github.com", userinfo_endpoint="https://api.github.com/user", - client_kwargs={"scope": GITHUB_CLIENT_SCOPE.value}, + client_kwargs={ + "scope": GITHUB_CLIENT_SCOPE.value, + **( + {"timeout": int(OAUTH_TIMEOUT.value)} + if OAUTH_TIMEOUT.value + else {} + ), + }, redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value, ) @@ -603,6 +634,9 @@ def load_oauth_providers(): def oidc_oauth_register(client): client_kwargs = { "scope": OAUTH_SCOPES.value, + **( + {"timeout": int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {} + ), } if ( @@ -895,6 +929,18 @@ except Exception: pass OPENAI_API_BASE_URL = "https://api.openai.com/v1" + +#################################### +# MODELS +#################################### + +ENABLE_BASE_MODELS_CACHE = PersistentConfig( + "ENABLE_BASE_MODELS_CACHE", + "models.base_models_cache", + os.environ.get("ENABLE_BASE_MODELS_CACHE", "False").lower() == "true", +) + + #################################### # TOOL_SERVERS #################################### @@ -1799,6 +1845,7 @@ QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334")) ENABLE_QDRANT_MULTITENANCY_MODE = ( os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "false").lower() == "true" ) +QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui") # OpenSearch OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200") diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 0f7b5611f5..dafb7be13a 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -199,6 +199,7 @@ CHANGELOG = changelog_json SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true" + #################################### # ENABLE_FORWARD_USER_INFO_HEADERS #################################### @@ -272,15 +273,13 @@ if "postgres://" in DATABASE_URL: DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None) -DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0) +DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", None) -if DATABASE_POOL_SIZE == "": - DATABASE_POOL_SIZE = 0 -else: +if DATABASE_POOL_SIZE != None: try: DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE) except Exception: - DATABASE_POOL_SIZE = 0 + DATABASE_POOL_SIZE = None DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0) @@ -325,6 +324,7 @@ ENABLE_REALTIME_CHAT_SAVE = ( #################################### REDIS_URL = os.environ.get("REDIS_URL", "") +REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui") REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "") REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379") @@ -396,10 +396,33 @@ WEBUI_AUTH_COOKIE_SECURE = ( if WEBUI_AUTH and WEBUI_SECRET_KEY == "": raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND) +ENABLE_COMPRESSION_MIDDLEWARE = ( + os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true" +) + +#################################### +# MODELS +#################################### + +MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1") +if MODELS_CACHE_TTL == "": + MODELS_CACHE_TTL = None +else: + try: + MODELS_CACHE_TTL = int(MODELS_CACHE_TTL) + except Exception: + MODELS_CACHE_TTL = 1 + + +#################################### +# WEBSOCKET SUPPORT +#################################### + ENABLE_WEBSOCKET_SUPPORT = ( os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true" ) + WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "") WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL) @@ -543,6 +566,9 @@ ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() == OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get( "OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317" ) +OTEL_EXPORTER_OTLP_INSECURE = ( + os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true" +) OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui") OTEL_RESOURCE_ATTRIBUTES = os.environ.get( "OTEL_RESOURCE_ATTRIBUTES", "" @@ -550,6 +576,14 @@ OTEL_RESOURCE_ATTRIBUTES = os.environ.get( OTEL_TRACES_SAMPLER = os.environ.get( "OTEL_TRACES_SAMPLER", "parentbased_always_on" ).lower() +OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "") +OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "") + + +OTEL_OTLP_SPAN_EXPORTER = os.environ.get( + "OTEL_OTLP_SPAN_EXPORTER", "grpc" +).lower() # grpc or http + #################################### # TOOLS/FUNCTIONS PIP OPTIONS diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index 840f571cc9..e1ffc1eb27 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -62,6 +62,9 @@ def handle_peewee_migration(DATABASE_URL): except Exception as e: log.error(f"Failed to initialize the database connection: {e}") + log.warning( + "Hint: If your database password contains special characters, you may need to URL-encode it." + ) raise finally: # Properly closing the database connection @@ -81,20 +84,23 @@ if "sqlite" in SQLALCHEMY_DATABASE_URL: SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} ) else: - if DATABASE_POOL_SIZE > 0: - engine = create_engine( - SQLALCHEMY_DATABASE_URL, - pool_size=DATABASE_POOL_SIZE, - max_overflow=DATABASE_POOL_MAX_OVERFLOW, - pool_timeout=DATABASE_POOL_TIMEOUT, - pool_recycle=DATABASE_POOL_RECYCLE, - pool_pre_ping=True, - poolclass=QueuePool, - ) + if isinstance(DATABASE_POOL_SIZE, int): + if DATABASE_POOL_SIZE > 0: + engine = create_engine( + SQLALCHEMY_DATABASE_URL, + pool_size=DATABASE_POOL_SIZE, + max_overflow=DATABASE_POOL_MAX_OVERFLOW, + pool_timeout=DATABASE_POOL_TIMEOUT, + pool_recycle=DATABASE_POOL_RECYCLE, + pool_pre_ping=True, + poolclass=QueuePool, + ) + else: + engine = create_engine( + SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool + ) else: - engine = create_engine( - SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool - ) + engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True) SessionLocal = sessionmaker( diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 544756a6e8..96b04aaed8 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -36,7 +36,6 @@ from fastapi import ( applications, BackgroundTasks, ) - from fastapi.openapi.docs import get_swagger_ui_html from fastapi.middleware.cors import CORSMiddleware @@ -49,6 +48,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.responses import Response, StreamingResponse +from starlette.datastructures import Headers from open_webui.utils import logger @@ -116,6 +116,8 @@ from open_webui.config import ( OPENAI_API_CONFIGS, # Direct Connections ENABLE_DIRECT_CONNECTIONS, + # Model list + ENABLE_BASE_MODELS_CACHE, # Thread pool size for FastAPI/AnyIO THREAD_POOL_SIZE, # Tool Server Configs @@ -396,6 +398,7 @@ from open_webui.env import ( AUDIT_LOG_LEVEL, CHANGELOG, REDIS_URL, + REDIS_KEY_PREFIX, REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT, GLOBAL_LOG_LEVEL, @@ -411,6 +414,7 @@ from open_webui.env import ( WEBUI_AUTH_TRUSTED_EMAIL_HEADER, WEBUI_AUTH_TRUSTED_NAME_HEADER, WEBUI_AUTH_SIGNOUT_REDIRECT_URL, + ENABLE_COMPRESSION_MIDDLEWARE, ENABLE_WEBSOCKET_SUPPORT, BYPASS_MODEL_ACCESS_CONTROL, RESET_CONFIG_ON_START, @@ -533,6 +537,27 @@ async def lifespan(app: FastAPI): asyncio.create_task(periodic_usage_pool_cleanup()) + if app.state.config.ENABLE_BASE_MODELS_CACHE: + await get_all_models( + Request( + # Creating a mock request object to pass to get_all_models + { + "type": "http", + "asgi.version": "3.0", + "asgi.spec_version": "2.0", + "method": "GET", + "path": "/internal", + "query_string": b"", + "headers": Headers({}).raw, + "client": ("127.0.0.1", 12345), + "server": ("127.0.0.1", 80), + "scheme": "http", + "app": app, + } + ), + None, + ) + yield if hasattr(app.state, "redis_task_command_listener"): @@ -553,6 +578,7 @@ app.state.instance_id = None app.state.config = AppConfig( redis_url=REDIS_URL, redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), + redis_key_prefix=REDIS_KEY_PREFIX, ) app.state.redis = None @@ -615,6 +641,15 @@ app.state.TOOL_SERVERS = [] app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS +######################################## +# +# MODELS +# +######################################## + +app.state.config.ENABLE_BASE_MODELS_CACHE = ENABLE_BASE_MODELS_CACHE +app.state.BASE_MODELS = [] + ######################################## # # WEBUI @@ -1072,7 +1107,9 @@ class RedirectMiddleware(BaseHTTPMiddleware): # Add the middleware to the app -app.add_middleware(CompressMiddleware) +if ENABLE_COMPRESSION_MIDDLEWARE: + app.add_middleware(CompressMiddleware) + app.add_middleware(RedirectMiddleware) app.add_middleware(SecurityHeadersMiddleware) @@ -1188,7 +1225,9 @@ if audit_level != AuditLevel.NONE: @app.get("/api/models") -async def get_models(request: Request, user=Depends(get_verified_user)): +async def get_models( + request: Request, refresh: bool = False, user=Depends(get_verified_user) +): def get_filtered_models(models, user): filtered_models = [] for model in models: @@ -1212,7 +1251,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)): return filtered_models - all_models = await get_all_models(request, user=user) + all_models = await get_all_models(request, refresh=refresh, user=user) models = [] for model in all_models: @@ -1507,6 +1546,7 @@ async def get_app_config(request: Request): "name": app.state.WEBUI_NAME, "version": VERSION, "default_locale": str(DEFAULT_LOCALE), + "offline_mode": OFFLINE_MODE, "oauth": { "providers": { name: config.get("name", name) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 0ac53a0233..9552d7f396 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -72,6 +72,8 @@ class ChatImportForm(ChatForm): meta: Optional[dict] = {} pinned: Optional[bool] = False folder_id: Optional[str] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None class ChatTitleMessagesForm(BaseModel): @@ -147,8 +149,16 @@ class ChatTable: "meta": form_data.meta, "pinned": form_data.pinned, "folder_id": form_data.folder_id, - "created_at": int(time.time()), - "updated_at": int(time.time()), + "created_at": ( + form_data.created_at + if form_data.created_at + else int(time.time()) + ), + "updated_at": ( + form_data.updated_at + if form_data.updated_at + else int(time.time()) + ), } ) diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index 8ac878fc22..a91496e8e8 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -14,7 +14,7 @@ from langchain_community.document_loaders import ( TextLoader, UnstructuredEPubLoader, UnstructuredExcelLoader, - UnstructuredMarkdownLoader, + UnstructuredODTLoader, UnstructuredPowerPointLoader, UnstructuredRSTLoader, UnstructuredXMLLoader, @@ -389,6 +389,8 @@ class Loader: loader = UnstructuredPowerPointLoader(file_path) elif file_ext == "msg": loader = OutlookMessageLoader(file_path) + elif file_ext == "odt": + loader = UnstructuredODTLoader(file_path) elif self._is_text_file(file_ext, file_content_type): loader = TextLoader(file_path, autodetect_encoding=True) else: diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 683f42819b..0a0f0dabab 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -7,6 +7,7 @@ import hashlib from concurrent.futures import ThreadPoolExecutor import time +from urllib.parse import quote from huggingface_hub import snapshot_download from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever from langchain_community.retrievers import BM25Retriever @@ -459,20 +460,19 @@ def get_sources_from_files( ) extracted_collections = [] - relevant_contexts = [] + query_results = [] for file in files: - - context = None + query_result = None if file.get("docs"): # BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL - context = { + query_result = { "documents": [[doc.get("content") for doc in file.get("docs")]], "metadatas": [[doc.get("metadata") for doc in file.get("docs")]], } elif file.get("context") == "full": # Manual Full Mode Toggle - context = { + query_result = { "documents": [[file.get("file").get("data", {}).get("content")]], "metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]], } @@ -499,7 +499,7 @@ def get_sources_from_files( } ) - context = { + query_result = { "documents": [documents], "metadatas": [metadatas], } @@ -507,7 +507,7 @@ def get_sources_from_files( elif file.get("id"): file_object = Files.get_file_by_id(file.get("id")) if file_object: - context = { + query_result = { "documents": [[file_object.data.get("content", "")]], "metadatas": [ [ @@ -520,7 +520,7 @@ def get_sources_from_files( ], } elif file.get("file").get("data"): - context = { + query_result = { "documents": [[file.get("file").get("data", {}).get("content")]], "metadatas": [ [file.get("file").get("data", {}).get("metadata", {})] @@ -548,19 +548,27 @@ def get_sources_from_files( if full_context: try: - context = get_all_items_from_collections(collection_names) + query_result = get_all_items_from_collections(collection_names) except Exception as e: log.exception(e) else: try: - context = None + query_result = None if file.get("type") == "text": - context = file["content"] + # Not sure when this is used, but it seems to be a fallback + query_result = { + "documents": [ + [file.get("file").get("data", {}).get("content")] + ], + "metadatas": [ + [file.get("file").get("data", {}).get("meta", {})] + ], + } else: if hybrid_search: try: - context = query_collection_with_hybrid_search( + query_result = query_collection_with_hybrid_search( collection_names=collection_names, queries=queries, embedding_function=embedding_function, @@ -576,8 +584,8 @@ def get_sources_from_files( " non hybrid search as fallback." ) - if (not hybrid_search) or (context is None): - context = query_collection( + if (not hybrid_search) or (query_result is None): + query_result = query_collection( collection_names=collection_names, queries=queries, embedding_function=embedding_function, @@ -588,24 +596,24 @@ def get_sources_from_files( extracted_collections.extend(collection_names) - if context: + if query_result: if "data" in file: del file["data"] - relevant_contexts.append({**context, "file": file}) + query_results.append({**query_result, "file": file}) sources = [] - for context in relevant_contexts: + for query_result in query_results: try: - if "documents" in context: - if "metadatas" in context: + if "documents" in query_result: + if "metadatas" in query_result: source = { - "source": context["file"], - "document": context["documents"][0], - "metadata": context["metadatas"][0], + "source": query_result["file"], + "document": query_result["documents"][0], + "metadata": query_result["metadatas"][0], } - if "distances" in context and context["distances"]: - source["distances"] = context["distances"][0] + if "distances" in query_result and query_result["distances"]: + source["distances"] = query_result["distances"][0] sources.append(source) except Exception as e: @@ -678,10 +686,10 @@ def generate_openai_batch_embeddings( "Authorization": f"Bearer {key}", **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -727,10 +735,10 @@ def generate_azure_openai_batch_embeddings( "api-key": key, **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -777,10 +785,10 @@ def generate_ollama_batch_embeddings( "Authorization": f"Bearer {key}", **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS else {} diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py index 60ef2d906c..7e16df3cfb 100644 --- a/backend/open_webui/retrieval/vector/dbs/opensearch.py +++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py @@ -157,10 +157,10 @@ class OpenSearchClient(VectorDBBase): for field, value in filter.items(): query_body["query"]["bool"]["filter"].append( - {"match": {"metadata." + str(field): value}} + {"term": {"metadata." + str(field) + ".keyword": value}} ) - size = limit if limit else 10 + size = limit if limit else 10000 try: result = self.client.search( @@ -206,6 +206,7 @@ class OpenSearchClient(VectorDBBase): for item in batch ] bulk(self.client, actions) + self.client.indices.refresh(self._get_index_name(collection_name)) def upsert(self, collection_name: str, items: list[VectorItem]): self._create_index_if_not_exists( @@ -228,6 +229,7 @@ class OpenSearchClient(VectorDBBase): for item in batch ] bulk(self.client, actions) + self.client.indices.refresh(self._get_index_name(collection_name)) def delete( self, @@ -251,11 +253,12 @@ class OpenSearchClient(VectorDBBase): } for field, value in filter.items(): query_body["query"]["bool"]["filter"].append( - {"match": {"metadata." + str(field): value}} + {"term": {"metadata." + str(field) + ".keyword": value}} ) self.client.delete_by_query( index=self._get_index_name(collection_name), body=query_body ) + self.client.indices.refresh(self._get_index_name(collection_name)) def reset(self): indices = self.client.indices.get(index=f"{self.index_prefix}_*") diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py index dfe2979076..2276e713fc 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant.py @@ -18,6 +18,7 @@ from open_webui.config import ( QDRANT_ON_DISK, QDRANT_GRPC_PORT, QDRANT_PREFER_GRPC, + QDRANT_COLLECTION_PREFIX, ) from open_webui.env import SRC_LOG_LEVELS @@ -29,7 +30,7 @@ log.setLevel(SRC_LOG_LEVELS["RAG"]) class QdrantClient(VectorDBBase): def __init__(self): - self.collection_prefix = "open-webui" + self.collection_prefix = QDRANT_COLLECTION_PREFIX self.QDRANT_URI = QDRANT_URI self.QDRANT_API_KEY = QDRANT_API_KEY self.QDRANT_ON_DISK = QDRANT_ON_DISK @@ -86,6 +87,25 @@ class QdrantClient(VectorDBBase): ), ) + # Create payload indexes for efficient filtering + self.client.create_payload_index( + collection_name=collection_name_with_prefix, + field_name="metadata.hash", + field_schema=models.KeywordIndexParams( + type=models.KeywordIndexType.KEYWORD, + is_tenant=False, + on_disk=self.QDRANT_ON_DISK, + ), + ) + self.client.create_payload_index( + collection_name=collection_name_with_prefix, + field_name="metadata.file_id", + field_schema=models.KeywordIndexParams( + type=models.KeywordIndexType.KEYWORD, + is_tenant=False, + on_disk=self.QDRANT_ON_DISK, + ), + ) log.info(f"collection {collection_name_with_prefix} successfully created!") def _create_collection_if_not_exists(self, collection_name, dimension): diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py index e83c437ef7..8f065ca5c8 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py @@ -9,6 +9,7 @@ from open_webui.config import ( QDRANT_ON_DISK, QDRANT_PREFER_GRPC, QDRANT_URI, + QDRANT_COLLECTION_PREFIX, ) from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.vector.main import ( @@ -30,7 +31,7 @@ log.setLevel(SRC_LOG_LEVELS["RAG"]) class QdrantClient(VectorDBBase): def __init__(self): - self.collection_prefix = "open-webui" + self.collection_prefix = QDRANT_COLLECTION_PREFIX self.QDRANT_URI = QDRANT_URI self.QDRANT_API_KEY = QDRANT_API_KEY self.QDRANT_ON_DISK = QDRANT_ON_DISK @@ -228,6 +229,25 @@ class QdrantClient(VectorDBBase): ), wait=True, ) + # Create payload indexes for efficient filtering on metadata.hash and metadata.file_id + self.client.create_payload_index( + collection_name=mt_collection_name, + field_name="metadata.hash", + field_schema=models.KeywordIndexParams( + type=models.KeywordIndexType.KEYWORD, + is_tenant=False, + on_disk=self.QDRANT_ON_DISK, + ), + ) + self.client.create_payload_index( + collection_name=mt_collection_name, + field_name="metadata.file_id", + field_schema=models.KeywordIndexParams( + type=models.KeywordIndexType.KEYWORD, + is_tenant=False, + on_disk=self.QDRANT_ON_DISK, + ), + ) log.info( f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!" diff --git a/backend/open_webui/retrieval/web/brave.py b/backend/open_webui/retrieval/web/brave.py index 3075db990f..7bea575620 100644 --- a/backend/open_webui/retrieval/web/brave.py +++ b/backend/open_webui/retrieval/web/brave.py @@ -36,7 +36,9 @@ def search_brave( return [ SearchResult( - link=result["url"], title=result.get("title"), snippet=result.get("snippet") + link=result["url"], + title=result.get("title"), + snippet=result.get("description"), ) for result in results[:count] ] diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 27634cec19..211f2ae859 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -15,6 +15,7 @@ import aiohttp import aiofiles import requests import mimetypes +from urllib.parse import quote from fastapi import ( Depends, @@ -343,10 +344,10 @@ async def speech(request: Request, user=Depends(get_verified_user)): "Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}", **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS else {} @@ -919,14 +920,18 @@ def transcription( ): log.info(f"file.content_type: {file.content_type}") - supported_content_types = request.app.state.config.STT_SUPPORTED_CONTENT_TYPES or [ - "audio/*", - "video/webm", - ] + stt_supported_content_types = getattr( + request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] + ) if not any( fnmatch(file.content_type, content_type) - for content_type in supported_content_types + for content_type in ( + stt_supported_content_types + if stt_supported_content_types + and any(t.strip() for t in stt_supported_content_types) + else ["audio/*", "video/webm"] + ) ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 60a12db4b3..106f3684a7 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -669,6 +669,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm): @router.get("/signout") async def signout(request: Request, response: Response): response.delete_cookie("token") + response.delete_cookie("oui-session") if ENABLE_OAUTH_SIGNUP.value: oauth_id_token = request.cookies.get("oauth_id_token") diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 29b12ed676..13b6040102 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -684,8 +684,10 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/share", response_model=Optional[ChatResponse]) async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)): - if not has_permission( - user.id, "chat.share", request.app.state.config.USER_PERMISSIONS + if (user.role != "admin") and ( + not has_permission( + user.id, "chat.share", request.app.state.config.USER_PERMISSIONS + ) ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 44b2ef40cf..a329584ca2 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -39,32 +39,39 @@ async def export_config(user=Depends(get_admin_user)): ############################ -# Direct Connections Config +# Connections Config ############################ -class DirectConnectionsConfigForm(BaseModel): +class ConnectionsConfigForm(BaseModel): ENABLE_DIRECT_CONNECTIONS: bool + ENABLE_BASE_MODELS_CACHE: bool -@router.get("/direct_connections", response_model=DirectConnectionsConfigForm) -async def get_direct_connections_config(request: Request, user=Depends(get_admin_user)): +@router.get("/connections", response_model=ConnectionsConfigForm) +async def get_connections_config(request: Request, user=Depends(get_admin_user)): return { "ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS, + "ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE, } -@router.post("/direct_connections", response_model=DirectConnectionsConfigForm) -async def set_direct_connections_config( +@router.post("/connections", response_model=ConnectionsConfigForm) +async def set_connections_config( request: Request, - form_data: DirectConnectionsConfigForm, + form_data: ConnectionsConfigForm, user=Depends(get_admin_user), ): request.app.state.config.ENABLE_DIRECT_CONNECTIONS = ( form_data.ENABLE_DIRECT_CONNECTIONS ) + request.app.state.config.ENABLE_BASE_MODELS_CACHE = ( + form_data.ENABLE_BASE_MODELS_CACHE + ) + return { "ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS, + "ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE, } diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index b9bb15c7b4..bdf5780fc4 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -155,17 +155,18 @@ def upload_file( if process: try: if file.content_type: - stt_supported_content_types = ( - request.app.state.config.STT_SUPPORTED_CONTENT_TYPES - or [ - "audio/*", - "video/webm", - ] + stt_supported_content_types = getattr( + request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] ) if any( fnmatch(file.content_type, content_type) - for content_type in stt_supported_content_types + for content_type in ( + stt_supported_content_types + if stt_supported_content_types + and any(t.strip() for t in stt_supported_content_types) + else ["audio/*", "video/webm"] + ) ): file_path = Storage.get_file(file_path) result = transcribe(request, file_path, file_metadata) diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 52686a5841..2832a11577 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -8,6 +8,7 @@ import re from pathlib import Path from typing import Optional +from urllib.parse import quote import requests from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from open_webui.config import CACHE_DIR @@ -302,8 +303,16 @@ async def update_image_config( ): set_image_model(request, form_data.MODEL) + if form_data.IMAGE_SIZE == "auto" and form_data.MODEL != "gpt-image-1": + raise HTTPException( + status_code=400, + detail=ERROR_MESSAGES.INCORRECT_FORMAT( + " (auto is only allowed with gpt-image-1)." + ), + ) + pattern = r"^\d+x\d+$" - if re.match(pattern, form_data.IMAGE_SIZE): + if form_data.IMAGE_SIZE == "auto" or re.match(pattern, form_data.IMAGE_SIZE): request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE else: raise HTTPException( @@ -471,7 +480,14 @@ async def image_generations( form_data: GenerateImageForm, user=Depends(get_verified_user), ): - width, height = tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x"))) + # if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default + # This is only relevant when the user has set IMAGE_SIZE to 'auto' with an + # image model other than gpt-image-1, which is warned about on settings save + width, height = ( + tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x"))) + if "x" in request.app.state.config.IMAGE_SIZE + else (512, 512) + ) r = None try: @@ -483,10 +499,10 @@ async def image_generations( headers["Content-Type"] = "application/json" if ENABLE_FORWARD_USER_INFO_HEADERS: - headers["X-OpenWebUI-User-Name"] = user.name - headers["X-OpenWebUI-User-Id"] = user.id - headers["X-OpenWebUI-User-Email"] = user.email - headers["X-OpenWebUI-User-Role"] = user.role + headers["X-OpenWebUI-User-Name"] = quote(user.name) + headers["X-OpenWebUI-User-Id"] = quote(user.id) + headers["X-OpenWebUI-User-Email"] = quote(user.email) + headers["X-OpenWebUI-User-Role"] = quote(user.role) data = { "model": ( diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 1353599374..3887106ad2 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -16,6 +16,7 @@ from urllib.parse import urlparse import aiohttp from aiocache import cached import requests +from urllib.parse import quote from open_webui.models.chats import Chats from open_webui.models.users import UserModel @@ -58,6 +59,7 @@ from open_webui.config import ( from open_webui.env import ( ENV, SRC_LOG_LEVELS, + MODELS_CACHE_TTL, AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, @@ -87,10 +89,10 @@ async def send_get_request(url, key=None, user: UserModel = None): **({"Authorization": f"Bearer {key}"} if key else {}), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -138,10 +140,10 @@ async def send_post_request( **({"Authorization": f"Bearer {key}"} if key else {}), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -242,10 +244,10 @@ async def verify_connection( **({"Authorization": f"Bearer {key}"} if key else {}), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -329,7 +331,7 @@ def merge_ollama_models_lists(model_lists): return list(merged_models.values()) -@cached(ttl=1) +@cached(ttl=MODELS_CACHE_TTL) async def get_all_models(request: Request, user: UserModel = None): log.info("get_all_models()") if request.app.state.config.ENABLE_OLLAMA_API: @@ -462,10 +464,10 @@ async def get_ollama_tags( **({"Authorization": f"Bearer {key}"} if key else {}), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -824,10 +826,10 @@ async def copy_model( **({"Authorization": f"Bearer {key}"} if key else {}), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -890,10 +892,10 @@ async def delete_model( **({"Authorization": f"Bearer {key}"} if key else {}), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -949,10 +951,10 @@ async def show_model_info( **({"Authorization": f"Bearer {key}"} if key else {}), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -1036,10 +1038,10 @@ async def embed( **({"Authorization": f"Bearer {key}"} if key else {}), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -1123,10 +1125,10 @@ async def embeddings( **({"Authorization": f"Bearer {key}"} if key else {}), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 7649271fee..a769c9a0c9 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -8,7 +8,7 @@ from typing import Literal, Optional, overload import aiohttp from aiocache import cached import requests - +from urllib.parse import quote from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter from fastapi.middleware.cors import CORSMiddleware @@ -21,6 +21,7 @@ from open_webui.config import ( CACHE_DIR, ) from open_webui.env import ( + MODELS_CACHE_TTL, AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, @@ -66,10 +67,10 @@ async def send_get_request(url, key=None, user: UserModel = None): **({"Authorization": f"Bearer {key}"} if key else {}), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -225,10 +226,10 @@ async def speech(request: Request, user=Depends(get_verified_user)): ), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS else {} @@ -386,7 +387,7 @@ async def get_filtered_models(models, user): return filtered_models -@cached(ttl=1) +@cached(ttl=MODELS_CACHE_TTL) async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: log.info("get_all_models()") @@ -478,10 +479,10 @@ async def get_models( "Content-Type": "application/json", **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS else {} @@ -573,10 +574,10 @@ async def verify_connection( "Content-Type": "application/json", **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS else {} @@ -633,13 +634,7 @@ async def verify_connection( raise HTTPException(status_code=500, detail=error_detail) -def convert_to_azure_payload( - url, - payload: dict, -): - model = payload.get("model", "") - - # Filter allowed parameters based on Azure OpenAI API +def get_azure_allowed_params(api_version: str) -> set[str]: allowed_params = { "messages", "temperature", @@ -669,6 +664,23 @@ def convert_to_azure_payload( "max_completion_tokens", } + try: + if api_version >= "2024-09-01-preview": + allowed_params.add("stream_options") + except ValueError: + log.debug( + f"Invalid API version {api_version} for Azure OpenAI. Defaulting to allowed parameters." + ) + + return allowed_params + + +def convert_to_azure_payload(url, payload: dict, api_version: str): + model = payload.get("model", "") + + # Filter allowed parameters based on Azure OpenAI API + allowed_params = get_azure_allowed_params(api_version) + # Special handling for o-series models if model.startswith("o") and model.endswith("-mini"): # Convert max_tokens to max_completion_tokens for o-series models @@ -806,10 +818,10 @@ async def generate_chat_completion( ), **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS else {} @@ -817,8 +829,8 @@ async def generate_chat_completion( } if api_config.get("azure", False): - request_url, payload = convert_to_azure_payload(url, payload) - api_version = api_config.get("api_version", "") or "2023-03-15-preview" + api_version = api_config.get("api_version", "2023-03-15-preview") + request_url, payload = convert_to_azure_payload(url, payload, api_version) headers["api-key"] = key headers["api-version"] = api_version request_url = f"{request_url}/chat/completions?api-version={api_version}" @@ -924,10 +936,10 @@ async def embeddings(request: Request, form_data: dict, user): "Content-Type": "application/json", **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS and user else {} @@ -996,10 +1008,10 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): "Content-Type": "application/json", **( { - "X-OpenWebUI-User-Name": user.name, - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, + "X-OpenWebUI-User-Name": quote(user.name), + "X-OpenWebUI-User-Id": quote(user.id), + "X-OpenWebUI-User-Email": quote(user.email), + "X-OpenWebUI-User-Role": quote(user.role), } if ENABLE_FORWARD_USER_INFO_HEADERS else {} @@ -1007,16 +1019,15 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): } if api_config.get("azure", False): + api_version = api_config.get("api_version", "2023-03-15-preview") headers["api-key"] = key - headers["api-version"] = ( - api_config.get("api_version", "") or "2023-03-15-preview" - ) + headers["api-version"] = api_version payload = json.loads(body) - url, payload = convert_to_azure_payload(url, payload) + url, payload = convert_to_azure_payload(url, payload, api_version) body = json.dumps(payload).encode() - request_url = f"{url}/{path}?api-version={api_config.get('api_version', '2023-03-15-preview')}" + request_url = f"{url}/{path}?api-version={api_version}" else: headers["Authorization"] = f"Bearer {key}" request_url = f"{url}/{path}" diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index ee6f99fbb5..a851abc2e5 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -1747,6 +1747,16 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: ) else: raise Exception("No TAVILY_API_KEY found in environment variables") + elif engine == "exa": + if request.app.state.config.EXA_API_KEY: + return search_exa( + request.app.state.config.EXA_API_KEY, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) + else: + raise Exception("No EXA_API_KEY found in environment variables") elif engine == "searchapi": if request.app.state.config.SEARCHAPI_API_KEY: return search_searchapi( @@ -1784,6 +1794,13 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) + elif engine == "exa": + return search_exa( + request.app.state.config.EXA_API_KEY, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) elif engine == "perplexity": return search_perplexity( request.app.state.config.PERPLEXITY_API_KEY, diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 35e40dccb2..96bcbcf1b5 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -1,4 +1,6 @@ import asyncio +import random + import socketio import logging import sys @@ -105,10 +107,26 @@ else: async def periodic_usage_pool_cleanup(): - if not aquire_func(): - log.debug("Usage pool cleanup lock already exists. Not running it.") - return - log.debug("Running periodic_usage_pool_cleanup") + max_retries = 2 + retry_delay = random.uniform( + WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT + ) + for attempt in range(max_retries + 1): + if aquire_func(): + break + else: + if attempt < max_retries: + log.debug( + f"Cleanup lock already exists. Retry {attempt + 1} after {retry_delay}s..." + ) + await asyncio.sleep(retry_delay) + else: + log.warning( + "Failed to acquire cleanup lock after retries. Skipping cleanup." + ) + return + + log.debug("Running periodic_cleanup") try: while True: if not renew_func(): diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 268c910e3e..83483f391b 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -419,7 +419,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A params[key] = value if "__user__" in sig.parameters: - __user__ = (user.model_dump() if isinstance(user, UserModel) else {},) + __user__ = user.model_dump() if isinstance(user, UserModel) else {} try: if hasattr(function_module, "UserValves"): diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index b1e69db264..ff4f901b76 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -248,6 +248,7 @@ async def chat_completion_tools_handler( if tool_id else f"{tool_function_name}" ) + if tool.get("metadata", {}).get("citation", False) or tool.get( "direct", False ): @@ -718,6 +719,10 @@ def apply_params_to_form_data(form_data, model): async def process_chat_payload(request, form_data, user, metadata, model): + # Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation + # -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling + # -> Chat Files + form_data = apply_params_to_form_data(form_data, model) log.debug(f"form_data: {form_data}") @@ -804,7 +809,6 @@ async def process_chat_payload(request, form_data, user, metadata, model): raise e try: - filter_functions = [ Functions.get_function_by_id(filter_id) for filter_id in get_sorted_filter_ids( @@ -912,7 +916,6 @@ async def process_chat_payload(request, form_data, user, metadata, model): request, form_data, extra_params, user, models, tools_dict ) sources.extend(flags.get("sources", [])) - except Exception as e: log.exception(e) @@ -925,24 +928,27 @@ async def process_chat_payload(request, form_data, user, metadata, model): # If context is not empty, insert it into the messages if len(sources) > 0: context_string = "" - citation_idx = {} + citation_idx_map = {} + for source in sources: if "document" in source: - for doc_context, doc_meta in zip( + for document_text, document_metadata in zip( source["document"], source["metadata"] ): source_name = source.get("source", {}).get("name", None) - citation_id = ( - doc_meta.get("source", None) + source_id = ( + document_metadata.get("source", None) or source.get("source", {}).get("id", None) or "N/A" ) - if citation_id not in citation_idx: - citation_idx[citation_id] = len(citation_idx) + 1 + + if source_id not in citation_idx_map: + citation_idx_map[source_id] = len(citation_idx_map) + 1 + context_string += ( - f'{doc_context}\n" + + f">{document_text}\n" ) context_string = context_string.strip() @@ -1370,7 +1376,7 @@ async def process_chat_response( return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0 # Handle as a background task - async def post_response_handler(response, events): + async def response_handler(response, events): def serialize_content_blocks(content_blocks, raw=False): content = "" @@ -1741,7 +1747,7 @@ async def process_chat_response( }, ) - async def stream_body_handler(response): + async def stream_body_handler(response, form_data): nonlocal content nonlocal content_blocks @@ -1770,7 +1776,7 @@ async def process_chat_response( filter_functions=filter_functions, filter_type="stream", form_data=data, - extra_params=extra_params, + extra_params={"__body__": form_data, **extra_params}, ) if data: @@ -2032,7 +2038,7 @@ async def process_chat_response( if response.background: await response.background() - await stream_body_handler(response) + await stream_body_handler(response, form_data) MAX_TOOL_CALL_RETRIES = 10 tool_call_retries = 0 @@ -2181,22 +2187,24 @@ async def process_chat_response( ) try: + new_form_data = { + "model": model_id, + "stream": True, + "tools": form_data["tools"], + "messages": [ + *form_data["messages"], + *convert_content_blocks_to_messages(content_blocks), + ], + } + res = await generate_chat_completion( request, - { - "model": model_id, - "stream": True, - "tools": form_data["tools"], - "messages": [ - *form_data["messages"], - *convert_content_blocks_to_messages(content_blocks), - ], - }, + new_form_data, user, ) if isinstance(res, StreamingResponse): - await stream_body_handler(res) + await stream_body_handler(res, new_form_data) else: break except Exception as e: @@ -2427,9 +2435,9 @@ async def process_chat_response( if response.background is not None: await response.background() - # background_tasks.add_task(post_response_handler, response, events) + # background_tasks.add_task(response_handler, response, events) task_id, _ = await create_task( - request, post_response_handler(response, events), id=metadata["chat_id"] + request, response_handler(response, events), id=metadata["chat_id"] ) return {"status": True, "task_id": task_id} diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index f637449ba9..493a6ee185 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -76,8 +76,16 @@ async def get_all_base_models(request: Request, user: UserModel = None): return function_models + openai_models + ollama_models -async def get_all_models(request, user: UserModel = None): - models = await get_all_base_models(request, user=user) +async def get_all_models(request, refresh: bool = False, user: UserModel = None): + if ( + request.app.state.MODELS + and request.app.state.BASE_MODELS + and (request.app.state.config.ENABLE_BASE_MODELS_CACHE and not refresh) + ): + models = request.app.state.BASE_MODELS + else: + models = await get_all_base_models(request, user=user) + request.app.state.BASE_MODELS = models # If there are no models, return an empty list if len(models) == 0: diff --git a/backend/open_webui/utils/telemetry/setup.py b/backend/open_webui/utils/telemetry/setup.py index 62632cff52..8209ba4131 100644 --- a/backend/open_webui/utils/telemetry/setup.py +++ b/backend/open_webui/utils/telemetry/setup.py @@ -1,9 +1,13 @@ from fastapi import FastAPI from opentelemetry import trace from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as HttpOTLPSpanExporter, +) from opentelemetry.sdk.resources import SERVICE_NAME, Resource from opentelemetry.sdk.trace import TracerProvider from sqlalchemy import Engine +from base64 import b64encode from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor from open_webui.utils.telemetry.instrumentors import Instrumentor @@ -11,7 +15,11 @@ from open_webui.utils.telemetry.metrics import setup_metrics from open_webui.env import ( OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_INSECURE, ENABLE_OTEL_METRICS, + OTEL_BASIC_AUTH_USERNAME, + OTEL_BASIC_AUTH_PASSWORD, + OTEL_OTLP_SPAN_EXPORTER, ) @@ -22,8 +30,27 @@ def setup(app: FastAPI, db_engine: Engine): resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME}) ) ) + + # Add basic auth header only if both username and password are not empty + headers = [] + if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD: + auth_string = f"{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}" + auth_header = b64encode(auth_string.encode()).decode() + headers = [("authorization", f"Basic {auth_header}")] + # otlp export - exporter = OTLPSpanExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT) + if OTEL_OTLP_SPAN_EXPORTER == "http": + exporter = HttpOTLPSpanExporter( + endpoint=OTEL_EXPORTER_OTLP_ENDPOINT, + insecure=OTEL_EXPORTER_OTLP_INSECURE, + headers=headers, + ) + else: + exporter = OTLPSpanExporter( + endpoint=OTEL_EXPORTER_OTLP_ENDPOINT, + insecure=OTEL_EXPORTER_OTLP_INSECURE, + headers=headers, + ) trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter)) Instrumentor(app=app, db_engine=db_engine).instrument() diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index dda2635ec7..02c8b3a86b 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -101,9 +101,6 @@ def get_tools( def make_tool_function(function_name, token, tool_server_data): async def tool_function(**kwargs): - print( - f"Executing tool function {function_name} with params: {kwargs}" - ) return await execute_tool_server( token=token, url=tool_server_data["url"], diff --git a/backend/requirements.txt b/backend/requirements.txt index c416e10b3b..b41365f9ea 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,6 @@ fastapi==0.115.7 -uvicorn[standard]==0.34.2 -pydantic==2.10.6 +uvicorn[standard]==0.35.0 +pydantic==2.11.7 python-multipart==0.0.20 python-socketio==5.13.0 @@ -42,8 +42,8 @@ google-genai==1.15.0 google-generativeai==0.8.5 tiktoken -langchain==0.3.24 -langchain-community==0.3.23 +langchain==0.3.26 +langchain-community==0.3.26 fake-useragent==2.1.0 chromadb==0.6.3 @@ -114,7 +114,7 @@ pytest-docker~=3.1.1 googleapis-common-protos==1.63.2 google-cloud-storage==2.19.0 -azure-identity==1.21.0 +azure-identity==1.23.0 azure-storage-blob==12.24.1 diff --git a/backend/start_windows.bat b/backend/start_windows.bat index e38fdb2aa6..f350d11cd1 100644 --- a/backend/start_windows.bat +++ b/backend/start_windows.bat @@ -28,7 +28,7 @@ SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%" :: Check if WEBUI_SECRET_KEY and WEBUI_JWT_SECRET_KEY are not set -IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " ( +IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " ( echo Loading WEBUI_SECRET_KEY from file, not provided as an environment variable. IF NOT EXIST "%KEY_FILE%" ( diff --git a/docker-compose.otel.yaml b/docker-compose.otel.yaml new file mode 100644 index 0000000000..436a54cbe3 --- /dev/null +++ b/docker-compose.otel.yaml @@ -0,0 +1,24 @@ +services: + grafana: + image: grafana/otel-lgtm:latest + container_name: lgtm + ports: + - "3000:3000" # Grafana UI + - "4317:4317" # OTLP/gRPC + - "4318:4318" # OTLP/HTTP + restart: unless-stopped + + open-webui: + image: ghcr.io/open-webui/open-webui:main + container_name: open-webui + depends_on: [grafana] + environment: + - ENABLE_OTEL=true + - OTEL_EXPORTER_OTLP_ENDPOINT=http://grafana:4317 + - OTEL_SERVICE_NAME=open-webui + ports: + - "8088:8080" + networks: [default] + +networks: + default: diff --git a/package-lock.json b/package-lock.json index d17e571808..bace58c993 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@xyflow/svelte": "^0.1.19", "async": "^3.2.5", "bits-ui": "^0.21.15", + "chart.js": "^4.5.0", "codemirror": "^6.0.1", "codemirror-lang-elixir": "^4.0.0", "codemirror-lang-hcl": "^0.1.0", @@ -42,9 +43,10 @@ "file-saver": "^2.0.5", "focus-trap": "^7.6.4", "fuse.js": "^7.0.0", + "heic2any": "^0.0.4", "highlight.js": "^11.9.0", "html-entities": "^2.5.3", - "html2canvas-pro": "^1.5.8", + "html2canvas-pro": "^1.5.11", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "i18next-resources-to-backend": "^1.2.0", @@ -53,6 +55,7 @@ "jspdf": "^3.0.0", "katex": "^0.16.22", "kokoro-js": "^1.1.1", + "leaflet": "^1.9.4", "marked": "^9.1.0", "mermaid": "^11.6.0", "paneforge": "^0.0.6", @@ -70,7 +73,7 @@ "prosemirror-view": "^1.34.3", "pyodide": "^0.27.3", "socket.io-client": "^4.2.0", - "sortablejs": "^1.15.2", + "sortablejs": "^1.15.6", "svelte-sonner": "^0.3.19", "tippy.js": "^6.3.7", "turndown": "^7.2.0", @@ -1870,6 +1873,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@lezer/common": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", @@ -4723,6 +4732,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -7295,6 +7316,12 @@ "node": ">= 0.4" } }, + "node_modules/heic2any": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz", + "integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==", + "license": "MIT" + }, "node_modules/heimdalljs": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz", @@ -7379,9 +7406,9 @@ } }, "node_modules/html2canvas-pro": { - "version": "1.5.8", - "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.8.tgz", - "integrity": "sha512-bVGAU7IvhBwBlRAmX6QhekX8lsaxmYoF6zIwf/HNlHscjx+KN8jw/U4PQRYqeEVm9+m13hcS1l5ChJB9/e29Lw==", + "version": "1.5.11", + "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.11.tgz", + "integrity": "sha512-W4pEeKLG8+9a54RDOSiEKq7gRXXDzt0ORMaLXX+l6a3urSKbmnkmyzcRDCtgTOzmHLaZTLG2wiTQMJqKLlSh3w==", "license": "MIT", "dependencies": { "css-line-break": "^2.1.0", @@ -8046,6 +8073,12 @@ "node": ">=10.13.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -11138,9 +11171,10 @@ } }, "node_modules/sortablejs": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz", - "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==" + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "license": "MIT" }, "node_modules/source-map-js": { "version": "1.2.1", diff --git a/package.json b/package.json index 7f0d121be7..1a0cb5263d 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@xyflow/svelte": "^0.1.19", "async": "^3.2.5", "bits-ui": "^0.21.15", + "chart.js": "^4.5.0", "codemirror": "^6.0.1", "codemirror-lang-elixir": "^4.0.0", "codemirror-lang-hcl": "^0.1.0", @@ -86,9 +87,10 @@ "file-saver": "^2.0.5", "focus-trap": "^7.6.4", "fuse.js": "^7.0.0", + "heic2any": "^0.0.4", "highlight.js": "^11.9.0", "html-entities": "^2.5.3", - "html2canvas-pro": "^1.5.8", + "html2canvas-pro": "^1.5.11", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "i18next-resources-to-backend": "^1.2.0", @@ -97,6 +99,7 @@ "jspdf": "^3.0.0", "katex": "^0.16.22", "kokoro-js": "^1.1.1", + "leaflet": "^1.9.4", "marked": "^9.1.0", "mermaid": "^11.6.0", "paneforge": "^0.0.6", @@ -114,7 +117,7 @@ "prosemirror-view": "^1.34.3", "pyodide": "^0.27.3", "socket.io-client": "^4.2.0", - "sortablejs": "^1.15.2", + "sortablejs": "^1.15.6", "svelte-sonner": "^0.3.19", "tippy.js": "^6.3.7", "turndown": "^7.2.0", diff --git a/pyproject.toml b/pyproject.toml index b20d92fcee..00d547988f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ license = { file = "LICENSE" } dependencies = [ "fastapi==0.115.7", "uvicorn[standard]==0.34.2", - "pydantic==2.10.6", + "pydantic==2.11.7", "python-multipart==0.0.20", "python-socketio==5.13.0", @@ -50,8 +50,8 @@ dependencies = [ "google-generativeai==0.8.5", "tiktoken", - "langchain==0.3.24", - "langchain-community==0.3.23", + "langchain==0.3.26", + "langchain-community==0.3.26", "fake-useragent==2.1.0", "chromadb==0.6.3", @@ -138,7 +138,7 @@ requires-python = ">= 3.11, < 3.13.0a1" dynamic = ["version"] classifiers = [ "Development Status :: 4 - Beta", - "License :: OSI Approved :: MIT License", + "License :: Other/Proprietary License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", diff --git a/scripts/prepare-pyodide.js b/scripts/prepare-pyodide.js index 664683a30d..d3eeaeb28a 100644 --- a/scripts/prepare-pyodide.js +++ b/scripts/prepare-pyodide.js @@ -74,8 +74,8 @@ async function downloadPackages() { console.log('Pyodide version mismatch, removing static/pyodide directory'); await rmdir('static/pyodide', { recursive: true }); } - } catch (e) { - console.log('Pyodide package not found, proceeding with download.'); + } catch (err) { + console.log('Pyodide package not found, proceeding with download.', err); } try { diff --git a/src/app.html b/src/app.html index 6fa5b79cee..94649bf2e9 100644 --- a/src/app.html +++ b/src/app.html @@ -33,6 +33,7 @@ @@ -120,18 +110,6 @@ } - @@ -524,29 +516,7 @@ {#if loading}
- +
{/if} diff --git a/src/lib/components/AddServerModal.svelte b/src/lib/components/AddServerModal.svelte index 1c9ce46e24..acec5c4add 100644 --- a/src/lib/components/AddServerModal.svelte +++ b/src/lib/components/AddServerModal.svelte @@ -3,6 +3,7 @@ import { getContext, onMount } from 'svelte'; const i18n = getContext('i18n'); + import { settings } from '$lib/stores'; import Modal from '$lib/components/common/Modal.svelte'; import Plus from '$lib/components/icons/Plus.svelte'; import Minus from '$lib/components/icons/Minus.svelte'; @@ -14,6 +15,8 @@ import { getToolServerData } from '$lib/apis'; import { verifyToolServerConnection } from '$lib/apis/configs'; import AccessControl from './workspace/common/AccessControl.svelte'; + import Spinner from '$lib/components/common/Spinner.svelte'; + import XMark from '$lib/components/icons/XMark.svelte'; export let onSubmit: Function = () => {}; export let onDelete: Function = () => {}; @@ -153,29 +156,21 @@
-
+

{#if edit} {$i18n.t('Edit Connection')} {:else} {$i18n.t('Add Connection')} {/if} -

+
@@ -192,12 +187,17 @@
-
{$i18n.t('URL')}
+
{ verifyHandler(); }} + aria-label={$i18n.t('Verify Connection')} type="button" >
+
-
+
{$i18n.t(`WebUI will make requests to "{{url}}"`, { url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}` })} @@ -257,12 +265,17 @@
-
{$i18n.t('Auth')}
+
-
{$i18n.t('Description')}
+
- +
{/if} diff --git a/src/lib/components/ChangelogModal.svelte b/src/lib/components/ChangelogModal.svelte index 21f0f5a06f..ea65760628 100644 --- a/src/lib/components/ChangelogModal.svelte +++ b/src/lib/components/ChangelogModal.svelte @@ -9,6 +9,7 @@ import Modal from './common/Modal.svelte'; import { updateUserSettings } from '$lib/apis/users'; + import XMark from '$lib/components/icons/XMark.svelte'; const i18n = getContext('i18n'); @@ -36,18 +37,11 @@ localStorage.version = $config.version; show = false; }} + aria-label={$i18n.t('Close')} > - +

{$i18n.t('Close')}

- - +
diff --git a/src/lib/components/ImportModal.svelte b/src/lib/components/ImportModal.svelte index e0a64a7a58..a8e525e976 100644 --- a/src/lib/components/ImportModal.svelte +++ b/src/lib/components/ImportModal.svelte @@ -3,7 +3,9 @@ import { getContext, onMount } from 'svelte'; const i18n = getContext('i18n'); + import Spinner from '$lib/components/common/Spinner.svelte'; import Modal from '$lib/components/common/Modal.svelte'; + import XMark from '$lib/components/icons/XMark.svelte'; import { extractFrontmatter } from '$lib/utils'; export let show = false; @@ -69,16 +71,7 @@ show = false; }} > - - - +
@@ -120,29 +113,7 @@ {#if loading}
- +
{/if} diff --git a/src/lib/components/admin/Evaluations/FeedbackModal.svelte b/src/lib/components/admin/Evaluations/FeedbackModal.svelte index 3469e7b26b..afff17de10 100644 --- a/src/lib/components/admin/Evaluations/FeedbackModal.svelte +++ b/src/lib/components/admin/Evaluations/FeedbackModal.svelte @@ -2,16 +2,42 @@ import Modal from '$lib/components/common/Modal.svelte'; import { getContext } from 'svelte'; const i18n = getContext('i18n'); + import XMark from '$lib/components/icons/XMark.svelte'; + import { getFeedbackById } from '$lib/apis/evaluations'; + import { toast } from 'svelte-sonner'; + import Spinner from '$lib/components/common/Spinner.svelte'; export let show = false; export let selectedFeedback = null; export let onClose: () => void = () => {}; + let loaded = false; + + let feedbackData = null; + const close = () => { show = false; onClose(); }; + + const init = async () => { + loaded = false; + feedbackData = null; + if (selectedFeedback) { + feedbackData = await getFeedbackById(localStorage.token, selectedFeedback.id).catch((err) => { + toast.error(err); + return null; + }); + + console.log('Feedback Data:', selectedFeedback, feedbackData); + } + loaded = true; + }; + + $: if (show) { + init(); + } @@ -22,58 +48,89 @@ {$i18n.t('Feedback Details')}
-
-
-
{$i18n.t('Rating')}
+ {#if loaded} +
+ {#if feedbackData} + {@const messageId = feedbackData?.meta?.message_id} + {@const messages = feedbackData?.snapshot?.chat?.chat?.history.messages} -
- {selectedFeedback?.data?.details?.rating ?? '-'} -
-
-
-
{$i18n.t('Reason')}
+ {#if messages[messages[messageId]?.parentId]} +
+
{$i18n.t('Prompt')}
-
- {selectedFeedback?.data?.reason || '-'} -
-
+
+ {messages[messages[messageId]?.parentId]?.content || '-'} +
+
+ {/if} -
- {#if selectedFeedback?.data?.tags && selectedFeedback?.data?.tags.length} -
- {#each selectedFeedback?.data?.tags as tag} - {tag} +
{$i18n.t('Response')}
+
- {/each} -
- {:else} - - + {messages[messageId]?.content || '-'} +
+
+ {/if} {/if} + +
+
{$i18n.t('Rating')}
+ +
+ {selectedFeedback?.data?.details?.rating ?? '-'} +
+
+
+
{$i18n.t('Reason')}
+ +
+ {selectedFeedback?.data?.reason || '-'} +
+
+ +
+
{$i18n.t('Comment')}
+ +
+ {selectedFeedback?.data?.comment || '-'} +
+
+ + {#if selectedFeedback?.data?.tags && selectedFeedback?.data?.tags.length} +
+
+ {#each selectedFeedback?.data?.tags as tag} + {tag} + {/each} +
+
+ {/if} + +
+ +
-
- + {:else} +
+
-
+ {/if}
{/if} diff --git a/src/lib/components/admin/Evaluations/Feedbacks.svelte b/src/lib/components/admin/Evaluations/Feedbacks.svelte index 0dcf02e1c1..c2ede2e91f 100644 --- a/src/lib/components/admin/Evaluations/Feedbacks.svelte +++ b/src/lib/components/admin/Evaluations/Feedbacks.svelte @@ -305,7 +305,7 @@ {#each paginatedFeedbacks as feedback (feedback.id)} openFeedbackModal(feedback)} > @@ -369,7 +369,7 @@ {dayjs(feedback.updated_at * 1000).fromNow()} - + e.stopPropagation()}> { deleteFeedbackHandler(feedback.id); diff --git a/src/lib/components/admin/Evaluations/Leaderboard.svelte b/src/lib/components/admin/Evaluations/Leaderboard.svelte index 46daf21278..29630d68d5 100644 --- a/src/lib/components/admin/Evaluations/Leaderboard.svelte +++ b/src/lib/components/admin/Evaluations/Leaderboard.svelte @@ -11,7 +11,7 @@ import Spinner from '$lib/components/common/Spinner.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; - import MagnifyingGlass from '$lib/components/icons/MagnifyingGlass.svelte'; + import Search from '$lib/components/icons/Search.svelte'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; @@ -77,7 +77,7 @@ let showLeaderboardModal = false; let selectedModel = null; - const openFeedbackModal = (model) => { + const openLeaderboardModelModal = (model) => { showLeaderboardModal = true; selectedModel = model; }; @@ -350,7 +350,7 @@
- +
- +
{/if} @@ -504,8 +504,8 @@ {#each sortedModels as model, modelIdx (model.id)} openFeedbackModal(model)} + class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition" + on:click={() => openLeaderboardModelModal(model)} >
diff --git a/src/lib/components/admin/Evaluations/LeaderboardModal.svelte b/src/lib/components/admin/Evaluations/LeaderboardModal.svelte index e90405630b..b3d7849797 100644 --- a/src/lib/components/admin/Evaluations/LeaderboardModal.svelte +++ b/src/lib/components/admin/Evaluations/LeaderboardModal.svelte @@ -6,6 +6,7 @@ export let feedbacks = []; export let onClose: () => void = () => {}; const i18n = getContext('i18n'); + import XMark from '$lib/components/icons/XMark.svelte'; const close = () => { show = false; @@ -37,25 +38,16 @@ {model.name}
{#if topTags.length} -
+
{#each topTags as tagInfo} - - {tagInfo.tag} ({tagInfo.count}) + + {tagInfo.tag} {tagInfo.count} {/each}
@@ -63,7 +55,7 @@ - {/if}
-
+
+ +
+ +
+ {#each OPENAI_API_BASE_URLS as url, idx} + { + updateOpenAIHandler(); + }} + onDelete={() => { + OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter( + (url, urlIdx) => idx !== urlIdx + ); + OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx); + + let newConfig = {}; + OPENAI_API_BASE_URLS.forEach((url, newIdx) => { + newConfig[newIdx] = + OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1]; + }); + OPENAI_API_CONFIGS = newConfig; + updateOpenAIHandler(); + }} + /> + {/each} +
+
+ {/if} +
+
+ +
+
+
{$i18n.t('Ollama API')}
+ +
+ { + updateOllamaHandler(); + }} + /> +
- {#if ENABLE_OPENAI_API} -
- + {#if ENABLE_OLLAMA_API}
-
{$i18n.t('Manage OpenAI API Connections')}
+
{$i18n.t('Manage Ollama API Connections')}
-
- {#each OPENAI_API_BASE_URLS as url, idx} - { - updateOpenAIHandler(); - }} - onDelete={() => { - OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter( - (url, urlIdx) => idx !== urlIdx - ); - OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx); +
+
+ {#each OLLAMA_BASE_URLS as url, idx} + { + updateOllamaHandler(); + }} + onDelete={() => { + OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx); - let newConfig = {}; - OPENAI_API_BASE_URLS.forEach((url, newIdx) => { - newConfig[newIdx] = OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1]; - }); - OPENAI_API_CONFIGS = newConfig; - updateOpenAIHandler(); - }} - /> - {/each} + let newConfig = {}; + OLLAMA_BASE_URLS.forEach((url, newIdx) => { + newConfig[newIdx] = + OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1]; + }); + OLLAMA_API_CONFIGS = newConfig; + }} + /> + {/each} +
+
+ +
+ {$i18n.t('Trouble accessing Ollama?')} + + {$i18n.t('Click here for help.')} +
{/if}
-
-
+
+
+
{$i18n.t('Direct Connections')}
-
-
-
{$i18n.t('Ollama API')}
- -
- { - updateOllamaHandler(); - }} - /> -
-
- - {#if ENABLE_OLLAMA_API} -
- -
-
-
{$i18n.t('Manage Ollama API Connections')}
- - - - -
- -
-
- {#each OLLAMA_BASE_URLS as url, idx} - { - updateOllamaHandler(); - }} - onDelete={() => { - OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx); - - let newConfig = {}; - OLLAMA_BASE_URLS.forEach((url, newIdx) => { - newConfig[newIdx] = OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1]; - }); - OLLAMA_API_CONFIGS = newConfig; - }} - /> - {/each} + />
- -
- {$i18n.t('Trouble accessing Ollama?')} - - {$i18n.t('Click here for help.')} - -
- {/if} -
-
- -
-
-
{$i18n.t('Direct Connections')}
- -
-
- { - updateDirectConnectionsHandler(); - }} - /> -
+
+ {$i18n.t( + 'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.' + )}
-
-
+
+ +
+
+
{$i18n.t('Cache Base Model List')}
+ +
+
+ { + updateConnectionsHandler(); + }} + /> +
+
+
+ +
{$i18n.t( - 'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.' + 'Base Model List Cache speeds up access by fetching base models only at startup or on settings save—faster, but may not show recent base model changes.' )}
diff --git a/src/lib/components/admin/Settings/Connections/ManageOllamaModal.svelte b/src/lib/components/admin/Settings/Connections/ManageOllamaModal.svelte index a2f9430704..a9d34d5c7e 100644 --- a/src/lib/components/admin/Settings/Connections/ManageOllamaModal.svelte +++ b/src/lib/components/admin/Settings/Connections/ManageOllamaModal.svelte @@ -5,6 +5,7 @@ import Modal from '$lib/components/common/Modal.svelte'; import ManageOllama from '../Models/Manage/ManageOllama.svelte'; + import XMark from '$lib/components/icons/XMark.svelte'; export let show = false; export let urlIdx: number | null = null; @@ -26,16 +27,7 @@ show = false; }} > - - - +
diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index e05abf686c..c2f2b5e671 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -90,10 +90,6 @@ return; } - if (embeddingEngine === 'openai' && (OpenAIKey === '' || OpenAIUrl === '')) { - toast.error($i18n.t('OpenAI URL/Key required.')); - return; - } if ( embeddingEngine === 'azure_openai' && (AzureOpenAIKey === '' || AzureOpenAIUrl === '' || AzureOpenAIVersion === '') @@ -731,7 +727,11 @@ required /> - +
{:else if embeddingEngine === 'ollama'}
@@ -808,33 +808,7 @@ > {#if updateEmbeddingModelLoading}
- - - - - +
{:else} {:else}
- +
{/if} diff --git a/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte b/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte index 08bc0c2a1e..b34c3e5275 100644 --- a/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte +++ b/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte @@ -3,6 +3,7 @@ const i18n = getContext('i18n'); const dispatch = createEventDispatcher(); + import Spinner from '$lib/components/common/Spinner.svelte'; import Modal from '$lib/components/common/Modal.svelte'; import { models } from '$lib/stores'; import Plus from '$lib/components/icons/Plus.svelte'; @@ -11,6 +12,7 @@ import { toast } from 'svelte-sonner'; import AccessControl from '$lib/components/workspace/common/AccessControl.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; + import XMark from '$lib/components/icons/XMark.svelte'; export let show = false; export let edit = false; @@ -141,16 +143,7 @@ show = false; }} > - - - +
@@ -406,29 +399,7 @@ {#if loading}
- +
{/if} diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index 132d5c5327..faad41d9b3 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -90,7 +90,9 @@ }; onMount(async () => { - checkForVersionUpdates(); + if (!$config?.offline_mode) { + checkForVersionUpdates(); + } await Promise.all([ (async () => { @@ -160,15 +162,17 @@
- + {#if !$config?.offline_mode} + + {/if}
diff --git a/src/lib/components/admin/Settings/Images.svelte b/src/lib/components/admin/Settings/Images.svelte index 003b991a0b..bc53678b83 100644 --- a/src/lib/components/admin/Settings/Images.svelte +++ b/src/lib/components/admin/Settings/Images.svelte @@ -13,9 +13,11 @@ updateConfig, verifyConfigUrl } from '$lib/apis/images'; + import Spinner from '$lib/components/common/Spinner.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Switch from '$lib/components/common/Switch.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; + import Textarea from '$lib/components/common/Textarea.svelte'; const dispatch = createEventDispatcher(); const i18n = getContext('i18n'); @@ -504,7 +506,7 @@
{$i18n.t('ComfyUI Workflow')}
{#if config.comfyui.COMFYUI_WORKFLOW} -