diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index d0f38c233..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' ---- - -# Bug Report - -## Important Notes - -- **Before submitting a bug report**: Please check the Issues or Discussions section to see if a similar issue or feature request has already been posted. It's likely we're already tracking it! If you’re unsure, start a discussion post first. This will help us efficiently focus on improving the project. - -- **Collaborate respectfully**: We value a constructive attitude, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We’re here to help if you’re open to learning and communicating positively. Remember, Open WebUI is a volunteer-driven project managed by a single maintainer and supported by contributors who also have full-time jobs. We appreciate your time and ask that you respect ours. - -- **Contributing**: If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI. - -- **Bug reproducibility**: If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a pip install with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "issues" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help! - -Note: Please remove the notes above when submitting your post. Thank you for your understanding and support! - ---- - -## Installation Method - -[Describe the method you used to install the project, e.g., git clone, Docker, pip, etc.] - -## Environment - -- **Open WebUI Version:** [e.g., v0.3.11] -- **Ollama (if applicable):** [e.g., v0.2.0, v0.1.32-rc1] - -- **Operating System:** [e.g., Windows 10, macOS Big Sur, Ubuntu 20.04] -- **Browser (if applicable):** [e.g., Chrome 100.0, Firefox 98.0] - -**Confirmation:** - -- [ ] I have read and followed all the instructions provided in the README.md. -- [ ] I am on the latest version of both Open WebUI and Ollama. -- [ ] I have included the browser console logs. -- [ ] I have included the Docker container logs. -- [ ] I have provided the exact steps to reproduce the bug in the "Steps to Reproduce" section below. - -## Expected Behavior: - -[Describe what you expected to happen.] - -## Actual Behavior: - -[Describe what actually happened.] - -## Description - -**Bug Summary:** -[Provide a brief but clear summary of the bug] - -## Reproduction Details - -**Steps to Reproduce:** -[Outline the steps to reproduce the bug. Be as detailed as possible.] - -## Logs and Screenshots - -**Browser Console Logs:** -[Include relevant browser console logs, if applicable] - -**Docker Container Logs:** -[Include relevant Docker container logs, if applicable] - -**Screenshots/Screen Recordings (if applicable):** -[Attach any relevant screenshots to help illustrate the issue] - -## Additional Information - -[Include any additional details that may help in understanding and reproducing the issue. This could include specific configurations, error messages, or anything else relevant to the bug.] - -## Note - -If the bug report is incomplete or does not follow the provided instructions, it may not be addressed. Please ensure that you have followed the steps outlined in the README.md and troubleshooting.md documents, and provide all necessary information for us to reproduce and address the issue. Thank you! diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 000000000..171a82ca8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,144 @@ +name: Bug Report +description: Create a detailed bug report to help us improve Open WebUI. +title: 'issue: ' +labels: ['bug', 'triage'] +assignees: [] +body: + - type: markdown + attributes: + value: | + # Bug Report + + ## Important Notes + + - **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. + + - **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication. + + - **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality. + + - **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated! + + - type: checkboxes + id: issue-check + attributes: + label: Check Existing Issues + description: Confirm that you’ve checked for existing reports before submitting a new one. + options: + - label: I have searched the existing issues and discussions. + required: true + + - type: dropdown + id: installation-method + attributes: + label: Installation Method + description: How did you install Open WebUI? + options: + - Git Clone + - Pip Install + - Docker + - Other + validations: + required: true + + - type: input + id: open-webui-version + attributes: + label: Open WebUI Version + description: Specify the version (e.g., v0.3.11) + validations: + required: true + + - type: input + id: ollama-version + attributes: + label: Ollama Version (if applicable) + description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1) + validations: + required: false + + - type: input + id: operating-system + attributes: + label: Operating System + description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04) + validations: + required: true + + - type: input + id: browser + attributes: + label: Browser (if applicable) + description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0) + validations: + required: false + + - type: checkboxes + id: confirmation + attributes: + label: Confirmation + description: Ensure the following prerequisites have been met. + options: + - label: I have read and followed all instructions in `README.md`. + required: true + - label: I am using the latest version of **both** Open WebUI and Ollama. + required: true + - label: I have checked the browser console logs. + required: true + - label: I have checked the Docker container logs. + required: true + - label: I have listed steps to reproduce the bug in detail. + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: Describe what should have happened. + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: Describe what actually happened. + validations: + required: true + + - type: textarea + id: reproduction-steps + attributes: + label: Steps to Reproduce + description: Provide step-by-step instructions to reproduce the issue. + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See the error message '...' + validations: + required: true + + - type: textarea + id: logs-screenshots + attributes: + label: Logs & Screenshots + description: Include relevant logs, errors, or screenshots to help diagnose the issue. + placeholder: 'Attach logs from the browser console, Docker logs, or error messages.' + validations: + required: true + + - type: textarea + id: additional-info + attributes: + label: Additional Information + description: Provide any extra details that may assist in understanding the issue. + validations: + required: false + + - type: markdown + attributes: + value: | + ## Note + If the bug report is incomplete or does not follow instructions, it may not be addressed. Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue. + Thank you for contributing to Open WebUI! diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index cc7a97c95..2a326f65e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,7 +1,7 @@ name: Feature Request description: Suggest an idea for this project -title: "[Feature Request]: " -labels: ["triage"] +title: 'feat: ' +labels: ['triage'] body: - type: markdown attributes: @@ -11,7 +11,7 @@ body: Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted. It's likely we're already tracking it! If you’re unsure, start a discussion post first. This will help us efficiently focus on improving the project. - + ### Collaborate respectfully We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**. @@ -19,16 +19,16 @@ body: - Open WebUI is a **volunteer-driven project** - It's managed by a **single maintainer** - It's supported by contributors who also have **full-time jobs** - + We appreciate your time and ask that you **respect ours**. - + ### Contributing If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI. - + ### Bug reproducibility If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "[issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help! - + - type: checkboxes id: existing-issue attributes: diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 03dcf8455..e61a69f33 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -14,7 +14,7 @@ env: jobs: build-main-image: - runs-on: ubuntu-latest + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write @@ -111,7 +111,7 @@ jobs: retention-days: 1 build-cuda-image: - runs-on: ubuntu-latest + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write @@ -211,7 +211,7 @@ jobs: retention-days: 1 build-ollama-image: - runs-on: ubuntu-latest + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 29715f6f3..7e1122870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.19] - 2024-03-04 + +### Added + +- **📊 Logit Bias Parameter Support**: Fine-tune conversation dynamics by adjusting the Logit Bias parameter directly in chat settings, giving you more control over model responses. +- **⌨️ Customizable Enter Behavior**: You can now configure Enter to send messages only when combined with Ctrl (Ctrl+Enter) via Settings > Interface, preventing accidental message sends. +- **📝 Collapsible Code Blocks**: Easily collapse long code blocks to declutter your chat, making it easier to focus on important details. +- **🏷️ Tag Selector in Model Selector**: Quickly find and categorize models with the new tag filtering system in the Model Selector, streamlining model discovery. +- **📈 Experimental Elasticsearch Vector DB Support**: Now supports Elasticsearch as a vector database, offering more flexibility for data retrieval in Retrieval-Augmented Generation (RAG) workflows. +- **⚙️ General Reliability Enhancements**: Various stability improvements across the WebUI, ensuring a smoother, more consistent experience. +- **🌍 Updated Translations**: Refined multilingual support for better localization and accuracy across various languages. + +### Fixed + +- **🔄 "Stream" Hook Activation**: Fixed an issue where the "Stream" hook only worked when globally enabled, ensuring reliable real-time filtering. +- **📧 LDAP Email Case Sensitivity**: Resolved an issue where LDAP login failed due to email case sensitivity mismatches, improving authentication reliability. +- **💬 WebSocket Chat Event Registration**: Fixed a bug preventing chat event listeners from being registered upon sign-in, ensuring real-time updates work properly. + ## [0.5.18] - 2025-02-27 ### Fixed diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 19abbf143..349c35ce5 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -587,6 +587,14 @@ load_oauth_providers() STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")).resolve() +for file_path in (FRONTEND_BUILD_DIR / "static").glob("**/*"): + if file_path.is_file(): + target_path = STATIC_DIR / file_path.relative_to( + (FRONTEND_BUILD_DIR / "static") + ) + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(file_path, target_path) + frontend_favicon = FRONTEND_BUILD_DIR / "static" / "favicon.png" if frontend_favicon.exists(): @@ -659,11 +667,7 @@ if CUSTOM_NAME: # LICENSE_KEY #################################### -LICENSE_KEY = PersistentConfig( - "LICENSE_KEY", - "license.key", - os.environ.get("LICENSE_KEY", ""), -) +LICENSE_KEY = os.environ.get("LICENSE_KEY", "") #################################### # STORAGE PROVIDER @@ -695,16 +699,16 @@ AZURE_STORAGE_KEY = os.environ.get("AZURE_STORAGE_KEY", None) # File Upload DIR #################################### -UPLOAD_DIR = f"{DATA_DIR}/uploads" -Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True) +UPLOAD_DIR = DATA_DIR / "uploads" +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) #################################### # Cache DIR #################################### -CACHE_DIR = f"{DATA_DIR}/cache" -Path(CACHE_DIR).mkdir(parents=True, exist_ok=True) +CACHE_DIR = DATA_DIR / "cache" +CACHE_DIR.mkdir(parents=True, exist_ok=True) #################################### @@ -1541,6 +1545,15 @@ OPENSEARCH_CERT_VERIFY = os.environ.get("OPENSEARCH_CERT_VERIFY", False) OPENSEARCH_USERNAME = os.environ.get("OPENSEARCH_USERNAME", None) OPENSEARCH_PASSWORD = os.environ.get("OPENSEARCH_PASSWORD", None) +# ElasticSearch +ELASTICSEARCH_URL = os.environ.get("ELASTICSEARCH_URL", "https://localhost:9200") +ELASTICSEARCH_CA_CERTS = os.environ.get("ELASTICSEARCH_CA_CERTS", None) +ELASTICSEARCH_API_KEY = os.environ.get("ELASTICSEARCH_API_KEY", None) +ELASTICSEARCH_USERNAME = os.environ.get("ELASTICSEARCH_USERNAME", None) +ELASTICSEARCH_PASSWORD = os.environ.get("ELASTICSEARCH_PASSWORD", None) +ELASTICSEARCH_CLOUD_ID = os.environ.get("ELASTICSEARCH_CLOUD_ID", None) +SSL_ASSERT_FINGERPRINT = os.environ.get("SSL_ASSERT_FINGERPRINT", None) + # Pgvector PGVECTOR_DB_URL = os.environ.get("PGVECTOR_DB_URL", DATABASE_URL) if VECTOR_DB == "pgvector" and not PGVECTOR_DB_URL.startswith("postgres"): @@ -1977,6 +1990,12 @@ EXA_API_KEY = PersistentConfig( os.getenv("EXA_API_KEY", ""), ) +PERPLEXITY_API_KEY = PersistentConfig( + "PERPLEXITY_API_KEY", + "rag.web.search.perplexity_api_key", + os.getenv("PERPLEXITY_API_KEY", ""), +) + RAG_WEB_SEARCH_RESULT_COUNT = PersistentConfig( "RAG_WEB_SEARCH_RESULT_COUNT", "rag.web.search.result_count", diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index ba546a2eb..3b3d6d4f3 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -65,10 +65,8 @@ except Exception: # LOGGING #################################### -log_levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] - GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper() -if GLOBAL_LOG_LEVEL in log_levels: +if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping(): logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True) else: GLOBAL_LOG_LEVEL = "INFO" @@ -78,6 +76,7 @@ log.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}") if "cuda_error" in locals(): log.exception(cuda_error) + del cuda_error log_sources = [ "AUDIO", @@ -100,7 +99,7 @@ SRC_LOG_LEVELS = {} for source in log_sources: log_env_var = source + "_LOG_LEVEL" SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper() - if SRC_LOG_LEVELS[source] not in log_levels: + if SRC_LOG_LEVELS[source] not in logging.getLevelNamesMapping(): SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}") @@ -386,6 +385,7 @@ ENABLE_WEBSOCKET_SUPPORT = ( WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "") WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL) +WEBSOCKET_REDIS_LOCK_TIMEOUT = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", 60) AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "") @@ -397,19 +397,20 @@ else: except Exception: AIOHTTP_CLIENT_TIMEOUT = 300 -AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = os.environ.get( - "AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "" +AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get( + "AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST", + os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", ""), ) -if AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST == "": - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = None + +if AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST == "": + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = None else: try: - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = int( - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST - ) + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = int(AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) except Exception: - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = 5 + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = 5 + #################################### # OFFLINE_MODE diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 9676d144e..779fcec2b 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -215,6 +215,7 @@ from open_webui.config import ( BING_SEARCH_V7_SUBSCRIPTION_KEY, BRAVE_SEARCH_API_KEY, EXA_API_KEY, + PERPLEXITY_API_KEY, KAGI_SEARCH_API_KEY, MOJEEK_SEARCH_API_KEY, BOCHA_SEARCH_API_KEY, @@ -400,8 +401,8 @@ async def lifespan(app: FastAPI): if RESET_CONFIG_ON_START: reset_config() - if app.state.config.LICENSE_KEY: - get_license_data(app, app.state.config.LICENSE_KEY) + if LICENSE_KEY: + get_license_data(app, LICENSE_KEY) asyncio.create_task(periodic_usage_pool_cleanup()) yield @@ -419,7 +420,7 @@ oauth_manager = OAuthManager(app) app.state.config = AppConfig() app.state.WEBUI_NAME = WEBUI_NAME -app.state.config.LICENSE_KEY = LICENSE_KEY +app.state.LICENSE_METADATA = None ######################################## # @@ -603,6 +604,7 @@ app.state.config.JINA_API_KEY = JINA_API_KEY app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY app.state.config.EXA_API_KEY = EXA_API_KEY +app.state.config.PERPLEXITY_API_KEY = PERPLEXITY_API_KEY app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = RAG_WEB_SEARCH_RESULT_COUNT app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = RAG_WEB_SEARCH_CONCURRENT_REQUESTS @@ -1019,7 +1021,7 @@ async def chat_completion( "files": form_data.get("files", None), "features": form_data.get("features", None), "variables": form_data.get("variables", None), - "model": model_info.model_dump() if model_info else model, + "model": model, "direct": model_item.get("direct", False), **( {"function_calling": "native"} @@ -1037,7 +1039,7 @@ async def chat_completion( form_data["metadata"] = metadata form_data, metadata, events = await process_chat_payload( - request, form_data, metadata, user, model + request, form_data, user, metadata, model ) except Exception as e: @@ -1051,7 +1053,7 @@ async def chat_completion( response = await chat_completion_handler(request, form_data, user) return await process_chat_response( - request, response, form_data, user, events, metadata, tasks + request, response, form_data, user, metadata, model, events, tasks ) except Exception as e: raise HTTPException( @@ -1140,9 +1142,10 @@ async def get_app_config(request: Request): if data is not None and "id" in data: user = Users.get_user_by_id(data["id"]) + user_count = Users.get_num_users() onboarding = False + if user is None: - user_count = Users.get_num_users() onboarding = user_count == 0 return { @@ -1188,6 +1191,7 @@ async def get_app_config(request: Request): { "default_models": app.state.config.DEFAULT_MODELS, "default_prompt_suggestions": app.state.config.DEFAULT_PROMPT_SUGGESTIONS, + "user_count": user_count, "code": { "engine": app.state.config.CODE_EXECUTION_ENGINE, }, @@ -1211,6 +1215,14 @@ async def get_app_config(request: Request): "api_key": GOOGLE_DRIVE_API_KEY.value, }, "onedrive": {"client_id": ONEDRIVE_CLIENT_ID.value}, + "license_metadata": app.state.LICENSE_METADATA, + **( + { + "active_entries": app.state.USER_COUNT, + } + if user.role == "admin" + else {} + ), } if user is not None else {} diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index b6253e63c..029a33a56 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -414,6 +414,13 @@ def get_sources_from_files( ] ], } + elif file.get("file").get("data"): + context = { + "documents": [[file.get("file").get("data", {}).get("content")]], + "metadatas": [ + [file.get("file").get("data", {}).get("metadata", {})] + ], + } else: collection_names = [] if file.get("type") == "collection": diff --git a/backend/open_webui/retrieval/vector/connector.py b/backend/open_webui/retrieval/vector/connector.py index bf97bc7b1..ac8884c04 100644 --- a/backend/open_webui/retrieval/vector/connector.py +++ b/backend/open_webui/retrieval/vector/connector.py @@ -16,6 +16,10 @@ elif VECTOR_DB == "pgvector": from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient VECTOR_DB_CLIENT = PgvectorClient() +elif VECTOR_DB == "elasticsearch": + from open_webui.retrieval.vector.dbs.elasticsearch import ElasticsearchClient + + VECTOR_DB_CLIENT = ElasticsearchClient() else: from open_webui.retrieval.vector.dbs.chroma import ChromaClient diff --git a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py new file mode 100644 index 000000000..2dc79d2c2 --- /dev/null +++ b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py @@ -0,0 +1,274 @@ +from elasticsearch import Elasticsearch, BadRequestError +from typing import Optional +import ssl +from elasticsearch.helpers import bulk, scan +from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult +from open_webui.config import ( + ELASTICSEARCH_URL, + ELASTICSEARCH_CA_CERTS, + ELASTICSEARCH_API_KEY, + ELASTICSEARCH_USERNAME, + ELASTICSEARCH_PASSWORD, + ELASTICSEARCH_CLOUD_ID, + SSL_ASSERT_FINGERPRINT, +) + + +class ElasticsearchClient: + """ + Important: + in order to reduce the number of indexes and since the embedding vector length is fixed, we avoid creating + an index for each file but store it as a text field, while seperating to different index + baesd on the embedding length. + """ + + def __init__(self): + self.index_prefix = "open_webui_collections" + self.client = Elasticsearch( + hosts=[ELASTICSEARCH_URL], + ca_certs=ELASTICSEARCH_CA_CERTS, + api_key=ELASTICSEARCH_API_KEY, + cloud_id=ELASTICSEARCH_CLOUD_ID, + basic_auth=( + (ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD) + if ELASTICSEARCH_USERNAME and ELASTICSEARCH_PASSWORD + else None + ), + ssl_assert_fingerprint=SSL_ASSERT_FINGERPRINT, + ) + + # Status: works + def _get_index_name(self, dimension: int) -> str: + return f"{self.index_prefix}_d{str(dimension)}" + + # Status: works + def _scan_result_to_get_result(self, result) -> GetResult: + if not result: + return None + ids = [] + documents = [] + metadatas = [] + + for hit in result: + ids.append(hit["_id"]) + documents.append(hit["_source"].get("text")) + metadatas.append(hit["_source"].get("metadata")) + + return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) + + # Status: works + def _result_to_get_result(self, result) -> GetResult: + if not result["hits"]["hits"]: + return None + ids = [] + documents = [] + metadatas = [] + + for hit in result["hits"]["hits"]: + ids.append(hit["_id"]) + documents.append(hit["_source"].get("text")) + metadatas.append(hit["_source"].get("metadata")) + + return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) + + # Status: works + def _result_to_search_result(self, result) -> SearchResult: + ids = [] + distances = [] + documents = [] + metadatas = [] + + for hit in result["hits"]["hits"]: + ids.append(hit["_id"]) + distances.append(hit["_score"]) + documents.append(hit["_source"].get("text")) + metadatas.append(hit["_source"].get("metadata")) + + return SearchResult( + ids=[ids], + distances=[distances], + documents=[documents], + metadatas=[metadatas], + ) + + # Status: works + def _create_index(self, dimension: int): + body = { + "mappings": { + "properties": { + "collection": {"type": "keyword"}, + "id": {"type": "keyword"}, + "vector": { + "type": "dense_vector", + "dims": dimension, # Adjust based on your vector dimensions + "index": True, + "similarity": "cosine", + }, + "text": {"type": "text"}, + "metadata": {"type": "object"}, + } + } + } + self.client.indices.create(index=self._get_index_name(dimension), body=body) + + # Status: works + + def _create_batches(self, items: list[VectorItem], batch_size=100): + for i in range(0, len(items), batch_size): + yield items[i : min(i + batch_size, len(items))] + + # Status: works + def has_collection(self, collection_name) -> bool: + query_body = {"query": {"bool": {"filter": []}}} + query_body["query"]["bool"]["filter"].append( + {"term": {"collection": collection_name}} + ) + + try: + result = self.client.count(index=f"{self.index_prefix}*", body=query_body) + + return result.body["count"] > 0 + except Exception as e: + return None + + # @TODO: Make this delete a collection and not an index + def delete_colleciton(self, collection_name: str): + # TODO: fix this to include the dimension or a * prefix + # delete_collection here means delete a bunch of documents for an index. + # We are simply adapting to the norms of the other DBs. + self.client.indices.delete(index=self._get_collection_name(collection_name)) + + # Status: works + def search( + self, collection_name: str, vectors: list[list[float]], limit: int + ) -> Optional[SearchResult]: + query = { + "size": limit, + "_source": ["text", "metadata"], + "query": { + "script_score": { + "query": { + "bool": {"filter": [{"term": {"collection": collection_name}}]} + }, + "script": { + "source": "cosineSimilarity(params.vector, 'vector') + 1.0", + "params": { + "vector": vectors[0] + }, # Assuming single query vector + }, + } + }, + } + + result = self.client.search( + index=self._get_index_name(len(vectors[0])), body=query + ) + + return self._result_to_search_result(result) + + # Status: only tested halfwat + def query( + self, collection_name: str, filter: dict, limit: Optional[int] = None + ) -> Optional[GetResult]: + if not self.has_collection(collection_name): + return None + + query_body = { + "query": {"bool": {"filter": []}}, + "_source": ["text", "metadata"], + } + + for field, value in filter.items(): + query_body["query"]["bool"]["filter"].append({"term": {field: value}}) + query_body["query"]["bool"]["filter"].append( + {"term": {"collection": collection_name}} + ) + size = limit if limit else 10 + + try: + result = self.client.search( + index=f"{self.index_prefix}*", + body=query_body, + size=size, + ) + + return self._result_to_get_result(result) + + except Exception as e: + return None + + # Status: works + def _has_index(self, dimension: int): + return self.client.indices.exists( + index=self._get_index_name(dimension=dimension) + ) + + def get_or_create_index(self, dimension: int): + if not self._has_index(dimension=dimension): + self._create_index(dimension=dimension) + + # Status: works + def get(self, collection_name: str) -> Optional[GetResult]: + # Get all the items in the collection. + query = { + "query": {"bool": {"filter": [{"term": {"collection": collection_name}}]}}, + "_source": ["text", "metadata"], + } + results = list(scan(self.client, index=f"{self.index_prefix}*", query=query)) + + return self._scan_result_to_get_result(results) + + # Status: works + def insert(self, collection_name: str, items: list[VectorItem]): + if not self._has_index(dimension=len(items[0]["vector"])): + self._create_index(dimension=len(items[0]["vector"])) + + for batch in self._create_batches(items): + actions = [ + { + "_index": self._get_index_name(dimension=len(items[0]["vector"])), + "_id": item["id"], + "_source": { + "collection": collection_name, + "vector": item["vector"], + "text": item["text"], + "metadata": item["metadata"], + }, + } + for item in batch + ] + bulk(self.client, actions) + + # Status: should work + def upsert(self, collection_name: str, items: list[VectorItem]): + if not self._has_index(dimension=len(items[0]["vector"])): + self._create_index(collection_name, dimension=len(items[0]["vector"])) + + for batch in self._create_batches(items): + actions = [ + { + "_index": self._get_index_name(dimension=len(items[0]["vector"])), + "_id": item["id"], + "_source": { + "vector": item["vector"], + "text": item["text"], + "metadata": item["metadata"], + }, + } + for item in batch + ] + self.client.bulk(actions) + + # TODO: This currently deletes by * which is not always supported in ElasticSearch. + # Need to read a bit before changing. Also, need to delete from a specific collection + def delete(self, collection_name: str, ids: list[str]): + # Assuming ID is unique across collections and indexes + actions = [ + {"delete": {"_index": f"{self.index_prefix}*", "_id": id}} for id in ids + ] + self.client.bulk(body=actions) + + def reset(self): + indices = self.client.indices.get(index=f"{self.index_prefix}*") + for index in indices: + self.client.indices.delete(index=index) diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index 3fd5b1ccd..ad05f9422 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -20,9 +20,9 @@ class MilvusClient: def __init__(self): self.collection_prefix = "open_webui" if MILVUS_TOKEN is None: - self.client = Client(uri=MILVUS_URI, database=MILVUS_DB) + self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB) else: - self.client = Client(uri=MILVUS_URI, database=MILVUS_DB, token=MILVUS_TOKEN) + self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB, token=MILVUS_TOKEN) def _result_to_get_result(self, result) -> GetResult: ids = [] diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py index b8186b3f9..2629bfcba 100644 --- a/backend/open_webui/retrieval/vector/dbs/opensearch.py +++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py @@ -49,7 +49,7 @@ class OpenSearchClient: ids=ids, distances=distances, documents=documents, metadatas=metadatas ) - def _create_index(self, index_name: str, dimension: int): + def _create_index(self, collection_name: str, dimension: int): body = { "mappings": { "properties": { @@ -72,24 +72,28 @@ class OpenSearchClient: } } } - self.client.indices.create(index=f"{self.index_prefix}_{index_name}", body=body) + self.client.indices.create( + index=f"{self.index_prefix}_{collection_name}", body=body + ) def _create_batches(self, items: list[VectorItem], batch_size=100): for i in range(0, len(items), batch_size): yield items[i : i + batch_size] - def has_collection(self, index_name: str) -> bool: + def has_collection(self, collection_name: str) -> bool: # has_collection here means has index. # We are simply adapting to the norms of the other DBs. - return self.client.indices.exists(index=f"{self.index_prefix}_{index_name}") + return self.client.indices.exists( + index=f"{self.index_prefix}_{collection_name}" + ) - def delete_colleciton(self, index_name: str): + def delete_colleciton(self, collection_name: str): # delete_collection here means delete index. # We are simply adapting to the norms of the other DBs. - self.client.indices.delete(index=f"{self.index_prefix}_{index_name}") + self.client.indices.delete(index=f"{self.index_prefix}_{collection_name}") def search( - self, index_name: str, vectors: list[list[float]], limit: int + self, collection_name: str, vectors: list[list[float]], limit: int ) -> Optional[SearchResult]: query = { "size": limit, @@ -108,7 +112,7 @@ class OpenSearchClient: } result = self.client.search( - index=f"{self.index_prefix}_{index_name}", body=query + index=f"{self.index_prefix}_{collection_name}", body=query ) return self._result_to_search_result(result) @@ -141,21 +145,22 @@ class OpenSearchClient: except Exception as e: return None - def get_or_create_index(self, index_name: str, dimension: int): - if not self.has_index(index_name): - self._create_index(index_name, dimension) + def _create_index_if_not_exists(self, collection_name: str, dimension: int): + if not self.has_index(collection_name): + self._create_index(collection_name, dimension) - def get(self, index_name: str) -> Optional[GetResult]: + def get(self, collection_name: str) -> Optional[GetResult]: query = {"query": {"match_all": {}}, "_source": ["text", "metadata"]} result = self.client.search( - index=f"{self.index_prefix}_{index_name}", body=query + index=f"{self.index_prefix}_{collection_name}", body=query ) return self._result_to_get_result(result) - def insert(self, index_name: str, items: list[VectorItem]): - if not self.has_index(index_name): - self._create_index(index_name, dimension=len(items[0]["vector"])) + def insert(self, collection_name: str, items: list[VectorItem]): + self._create_index_if_not_exists( + collection_name=collection_name, dimension=len(items[0]["vector"]) + ) for batch in self._create_batches(items): actions = [ @@ -173,15 +178,17 @@ class OpenSearchClient: ] self.client.bulk(actions) - def upsert(self, index_name: str, items: list[VectorItem]): - if not self.has_index(index_name): - self._create_index(index_name, dimension=len(items[0]["vector"])) + def upsert(self, collection_name: str, items: list[VectorItem]): + self._create_index_if_not_exists( + collection_name=collection_name, dimension=len(items[0]["vector"]) + ) for batch in self._create_batches(items): actions = [ { "index": { "_id": item["id"], + "_index": f"{self.index_prefix}_{collection_name}", "_source": { "vector": item["vector"], "text": item["text"], @@ -193,9 +200,9 @@ class OpenSearchClient: ] self.client.bulk(actions) - def delete(self, index_name: str, ids: list[str]): + def delete(self, collection_name: str, ids: list[str]): actions = [ - {"delete": {"_index": f"{self.index_prefix}_{index_name}", "_id": id}} + {"delete": {"_index": f"{self.index_prefix}_{collection_name}", "_id": id}} for id in ids ] self.client.bulk(body=actions) diff --git a/backend/open_webui/retrieval/web/perplexity.py b/backend/open_webui/retrieval/web/perplexity.py new file mode 100644 index 000000000..e5314eb1f --- /dev/null +++ b/backend/open_webui/retrieval/web/perplexity.py @@ -0,0 +1,87 @@ +import logging +from typing import Optional, List +import requests + +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_perplexity( + api_key: str, + query: str, + count: int, + filter_list: Optional[list[str]] = None, +) -> list[SearchResult]: + """Search using Perplexity API and return the results as a list of SearchResult objects. + + Args: + api_key (str): A Perplexity API key + query (str): The query to search for + count (int): Maximum number of results to return + + """ + + # Handle PersistentConfig object + if hasattr(api_key, "__str__"): + api_key = str(api_key) + + try: + url = "https://api.perplexity.ai/chat/completions" + + # Create payload for the API call + payload = { + "model": "sonar", + "messages": [ + { + "role": "system", + "content": "You are a search assistant. Provide factual information with citations.", + }, + {"role": "user", "content": query}, + ], + "temperature": 0.2, # Lower temperature for more factual responses + "stream": False, + } + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # Make the API request + response = requests.request("POST", url, json=payload, headers=headers) + + # Parse the JSON response + json_response = response.json() + + # Extract citations from the response + citations = json_response.get("citations", []) + + # Create search results from citations + results = [] + for i, citation in enumerate(citations[:count]): + # Extract content from the response to use as snippet + content = "" + if "choices" in json_response and json_response["choices"]: + if i == 0: + content = json_response["choices"][0]["message"]["content"] + + result = {"link": citation, "title": f"Source {i+1}", "snippet": content} + results.append(result) + + if filter_list: + + results = get_filtered_results(results, filter_list) + + return [ + SearchResult( + link=result["link"], title=result["title"], snippet=result["snippet"] + ) + for result in results[:count] + ] + + except Exception as e: + log.error(f"Error searching with Perplexity API: {e}") + return [] diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index c949e65a4..d6f74eac4 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -54,7 +54,7 @@ MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["AUDIO"]) -SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/") +SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech" SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index f01b5bd74..399283ee4 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -230,9 +230,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): entry = connection_app.entries[0] username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower() - mail = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"]) - if not mail or mail == "" or mail == "[]": - raise HTTPException(400, f"User {form_data.user} does not have mail.") + email = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"]) + if not email or email == "" or email == "[]": + raise HTTPException(400, f"User {form_data.user} does not have email.") + else: + email = email.lower() + cn = str(entry["cn"]) user_dn = entry.entry_dn @@ -247,7 +250,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): if not connection_user.bind(): raise HTTPException(400, f"Authentication failed for {form_data.user}") - user = Users.get_user_by_email(mail) + user = Users.get_user_by_email(email) if not user: try: user_count = Users.get_num_users() @@ -259,7 +262,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): ) user = Auths.insert_new_auth( - email=mail, password=str(uuid.uuid4()), name=cn, role=role + email=email, + password=str(uuid.uuid4()), + name=cn, + role=role, ) if not user: @@ -272,7 +278,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): except Exception as err: raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) - user = Auths.authenticate_user_by_trusted_header(mail) + user = Auths.authenticate_user_by_trusted_header(email) if user: token = create_token( diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index ac2db9322..206610138 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -74,7 +74,7 @@ async def create_new_function( function = Functions.insert_new_function(user.id, function_type, form_data) - function_cache_dir = Path(CACHE_DIR) / "functions" / form_data.id + function_cache_dir = CACHE_DIR / "functions" / form_data.id function_cache_dir.mkdir(parents=True, exist_ok=True) if function: diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 131fa2df4..c51d2f996 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -25,7 +25,7 @@ from pydantic import BaseModel log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["IMAGES"]) -IMAGE_CACHE_DIR = Path(CACHE_DIR).joinpath("./image/generations/") +IMAGE_CACHE_DIR = CACHE_DIR / "image" / "generations" IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -517,7 +517,13 @@ async def image_generations( images = [] for image in res["data"]: - image_data, content_type = load_b64_image_data(image["b64_json"]) + if "url" in image: + image_data, content_type = load_url_image_data( + image["url"], headers + ) + else: + image_data, content_type = load_b64_image_data(image["b64_json"]) + url = upload_image(request, data, image_data, content_type, user) images.append({"url": url}) return images diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index d99416c83..959b8417a 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -55,7 +55,7 @@ from open_webui.env import ( ENV, SRC_LOG_LEVELS, AIOHTTP_CLIENT_TIMEOUT, - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST, + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, BYPASS_MODEL_ACCESS_CONTROL, ) from open_webui.constants import ERROR_MESSAGES @@ -72,7 +72,7 @@ log.setLevel(SRC_LOG_LEVELS["OLLAMA"]) async def send_get_request(url, key=None, user: UserModel = None): - timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) try: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.get( @@ -216,7 +216,7 @@ async def verify_connection( key = form_data.key async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) ) as session: try: async with session.get( diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 990df83b0..73b182d3c 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -22,7 +22,7 @@ from open_webui.config import ( ) from open_webui.env import ( AIOHTTP_CLIENT_TIMEOUT, - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST, + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, ENABLE_FORWARD_USER_INFO_HEADERS, BYPASS_MODEL_ACCESS_CONTROL, ) @@ -53,7 +53,7 @@ log.setLevel(SRC_LOG_LEVELS["OPENAI"]) async def send_get_request(url, key=None, user: UserModel = None): - timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) try: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.get( @@ -192,7 +192,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): body = await request.body() name = hashlib.sha256(body).hexdigest() - SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/") + SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech" SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3") file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json") @@ -448,9 +448,7 @@ async def get_models( r = None async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout( - total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST - ) + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) ) as session: try: async with session.get( @@ -530,7 +528,7 @@ async def verify_connection( key = form_data.key async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) ) as session: try: async with session.get( diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 7dd324b80..ac38c236e 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -59,7 +59,7 @@ from open_webui.retrieval.web.serpstack import search_serpstack from open_webui.retrieval.web.tavily import search_tavily from open_webui.retrieval.web.bing import search_bing from open_webui.retrieval.web.exa import search_exa - +from open_webui.retrieval.web.perplexity import search_perplexity from open_webui.retrieval.utils import ( get_embedding_function, @@ -405,6 +405,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT, "bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, "exa_api_key": request.app.state.config.EXA_API_KEY, + "perplexity_api_key": request.app.state.config.PERPLEXITY_API_KEY, "result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, "trust_env": request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV, "concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, @@ -465,6 +466,7 @@ class WebSearchConfig(BaseModel): bing_search_v7_endpoint: Optional[str] = None bing_search_v7_subscription_key: Optional[str] = None exa_api_key: Optional[str] = None + perplexity_api_key: Optional[str] = None result_count: Optional[int] = None concurrent_requests: Optional[int] = None trust_env: Optional[bool] = None @@ -617,6 +619,10 @@ async def update_rag_config( request.app.state.config.EXA_API_KEY = form_data.web.search.exa_api_key + request.app.state.config.PERPLEXITY_API_KEY = ( + form_data.web.search.perplexity_api_key + ) + request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = ( form_data.web.search.result_count ) @@ -683,6 +689,7 @@ async def update_rag_config( "bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT, "bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, "exa_api_key": request.app.state.config.EXA_API_KEY, + "perplexity_api_key": request.app.state.config.PERPLEXITY_API_KEY, "result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, "concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, "trust_env": request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV, @@ -1182,9 +1189,13 @@ def process_web( content = " ".join([doc.page_content for doc in docs]) log.debug(f"text_content: {content}") - save_docs_to_vector_db( - request, docs, collection_name, overwrite=True, user=user - ) + + if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: + save_docs_to_vector_db( + request, docs, collection_name, overwrite=True, user=user + ) + else: + collection_name = None return { "status": True, @@ -1196,6 +1207,7 @@ def process_web( }, "meta": { "name": form_data.url, + "source": form_data.url, }, }, } @@ -1221,6 +1233,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: - SERPLY_API_KEY - TAVILY_API_KEY - EXA_API_KEY + - PERPLEXITY_API_KEY - SEARCHAPI_API_KEY + SEARCHAPI_ENGINE (by default `google`) - SERPAPI_API_KEY + SERPAPI_ENGINE (by default `google`) Args: @@ -1385,6 +1398,13 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, ) + elif engine == "perplexity": + return search_perplexity( + request.app.state.config.PERPLEXITY_API_KEY, + query, + request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + ) else: raise Exception("No search engine API key found in environment variables") diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 5e4109037..211264cde 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -105,7 +105,7 @@ async def create_new_tools( specs = get_tools_specs(TOOLS[form_data.id]) tools = Tools.insert_new_tool(user.id, form_data, specs) - tool_cache_dir = Path(CACHE_DIR) / "tools" / form_data.id + tool_cache_dir = CACHE_DIR / "tools" / form_data.id tool_cache_dir.mkdir(parents=True, exist_ok=True) if tools: diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 6f5915122..8f5a9568b 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -12,6 +12,7 @@ from open_webui.env import ( ENABLE_WEBSOCKET_SUPPORT, WEBSOCKET_MANAGER, WEBSOCKET_REDIS_URL, + WEBSOCKET_REDIS_LOCK_TIMEOUT, ) from open_webui.utils.auth import decode_token from open_webui.socket.utils import RedisDict, RedisLock @@ -61,7 +62,7 @@ if WEBSOCKET_MANAGER == "redis": clean_up_lock = RedisLock( redis_url=WEBSOCKET_REDIS_URL, lock_name="usage_cleanup_lock", - timeout_secs=TIMEOUT_DURATION * 2, + timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT, ) aquire_func = clean_up_lock.aquire_lock renew_func = clean_up_lock.renew_lock diff --git a/backend/open_webui/static/apple-touch-icon.png b/backend/open_webui/static/apple-touch-icon.png new file mode 100644 index 000000000..ece4b85db Binary files /dev/null and b/backend/open_webui/static/apple-touch-icon.png differ diff --git a/backend/open_webui/static/favicon-96x96.png b/backend/open_webui/static/favicon-96x96.png new file mode 100644 index 000000000..2ebdffebe Binary files /dev/null and b/backend/open_webui/static/favicon-96x96.png differ diff --git a/backend/open_webui/static/favicon-dark.png b/backend/open_webui/static/favicon-dark.png new file mode 100644 index 000000000..08627a23f Binary files /dev/null and b/backend/open_webui/static/favicon-dark.png differ diff --git a/backend/open_webui/static/favicon.ico b/backend/open_webui/static/favicon.ico new file mode 100644 index 000000000..14c5f9c6d Binary files /dev/null and b/backend/open_webui/static/favicon.ico differ diff --git a/backend/open_webui/static/favicon.svg b/backend/open_webui/static/favicon.svg new file mode 100644 index 000000000..0aa909745 --- /dev/null +++ b/backend/open_webui/static/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/static/favicon/site.webmanifest b/backend/open_webui/static/site.webmanifest similarity index 100% rename from static/favicon/site.webmanifest rename to backend/open_webui/static/site.webmanifest diff --git a/backend/open_webui/static/splash-dark.png b/backend/open_webui/static/splash-dark.png new file mode 100644 index 000000000..202c03f8e Binary files /dev/null and b/backend/open_webui/static/splash-dark.png differ diff --git a/backend/open_webui/static/web-app-manifest-192x192.png b/backend/open_webui/static/web-app-manifest-192x192.png new file mode 100644 index 000000000..fbd2eab6e Binary files /dev/null and b/backend/open_webui/static/web-app-manifest-192x192.png differ diff --git a/backend/open_webui/static/web-app-manifest-512x512.png b/backend/open_webui/static/web-app-manifest-512x512.png new file mode 100644 index 000000000..afebe2cd0 Binary files /dev/null and b/backend/open_webui/static/web-app-manifest-512x512.png differ diff --git a/backend/open_webui/storage/provider.py b/backend/open_webui/storage/provider.py index 2f31cbdaf..c5c0056cc 100644 --- a/backend/open_webui/storage/provider.py +++ b/backend/open_webui/storage/provider.py @@ -101,19 +101,33 @@ class LocalStorageProvider(StorageProvider): class S3StorageProvider(StorageProvider): def __init__(self): - self.s3_client = boto3.client( - "s3", - region_name=S3_REGION_NAME, - endpoint_url=S3_ENDPOINT_URL, - aws_access_key_id=S3_ACCESS_KEY_ID, - aws_secret_access_key=S3_SECRET_ACCESS_KEY, - config=Config( - s3={ - "use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT, - "addressing_style": S3_ADDRESSING_STYLE, - }, - ), + config = Config( + s3={ + "use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT, + "addressing_style": S3_ADDRESSING_STYLE, + }, ) + + # If access key and secret are provided, use them for authentication + if S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY: + self.s3_client = boto3.client( + "s3", + region_name=S3_REGION_NAME, + endpoint_url=S3_ENDPOINT_URL, + aws_access_key_id=S3_ACCESS_KEY_ID, + aws_secret_access_key=S3_SECRET_ACCESS_KEY, + config=config, + ) + else: + # If no explicit credentials are provided, fall back to default AWS credentials + # This supports workload identity (IAM roles for EC2, EKS, etc.) + self.s3_client = boto3.client( + "s3", + region_name=S3_REGION_NAME, + endpoint_url=S3_ENDPOINT_URL, + config=config, + ) + self.bucket_name = S3_BUCKET_NAME self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else "" diff --git a/backend/open_webui/test/apps/webui/storage/test_provider.py b/backend/open_webui/test/apps/webui/storage/test_provider.py index a5ef13504..3c874592f 100644 --- a/backend/open_webui/test/apps/webui/storage/test_provider.py +++ b/backend/open_webui/test/apps/webui/storage/test_provider.py @@ -187,6 +187,17 @@ class TestS3StorageProvider: assert not (upload_dir / self.filename).exists() assert not (upload_dir / self.filename_extra).exists() + def test_init_without_credentials(self, monkeypatch): + """Test that S3StorageProvider can initialize without explicit credentials.""" + # Temporarily unset the environment variables + monkeypatch.setattr(provider, "S3_ACCESS_KEY_ID", None) + monkeypatch.setattr(provider, "S3_SECRET_ACCESS_KEY", None) + + # Should not raise an exception + storage = provider.S3StorageProvider() + assert storage.s3_client is not None + assert storage.bucket_name == provider.S3_BUCKET_NAME + class TestGCSStorageProvider: Storage = provider.GCSStorageProvider() diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index cbc8b15ae..d0c02a569 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -83,11 +83,12 @@ def get_license_data(app, key): if k == "resources": for p, c in v.items(): globals().get("override_static", lambda a, b: None)(p, c) - elif k == "user_count": + elif k == "count": setattr(app.state, "USER_COUNT", v) - elif k == "webui_name": + elif k == "name": setattr(app.state, "WEBUI_NAME", v) - + elif k == "metadata": + setattr(app.state, "LICENSE_METADATA", v) return True else: log.error( diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 74d0af4f7..a6a06c522 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -149,7 +149,7 @@ async def generate_direct_chat_completion( } ) - if "error" in res: + if "error" in res and res["error"]: raise Exception(res["error"]) return res @@ -328,9 +328,14 @@ async def chat_completed(request: Request, form_data: dict, user: Any): } try: + filter_functions = [ + Functions.get_function_by_id(filter_id) + for filter_id in get_sorted_filter_ids(model) + ] + result, _ = await process_filter_functions( request=request, - filter_ids=get_sorted_filter_ids(model), + filter_functions=filter_functions, filter_type="outlet", form_data=data, extra_params=extra_params, diff --git a/backend/open_webui/utils/code_interpreter.py b/backend/open_webui/utils/code_interpreter.py index 0a74da9c7..312baff24 100644 --- a/backend/open_webui/utils/code_interpreter.py +++ b/backend/open_webui/utils/code_interpreter.py @@ -1,148 +1,210 @@ import asyncio import json +import logging import uuid +from typing import Optional + +import aiohttp import websockets -import requests -from urllib.parse import urljoin +from pydantic import BaseModel + +from open_webui.env import SRC_LOG_LEVELS + +logger = logging.getLogger(__name__) +logger.setLevel(SRC_LOG_LEVELS["MAIN"]) -async def execute_code_jupyter( - jupyter_url, code, token=None, password=None, timeout=10 -): +class ResultModel(BaseModel): """ - Executes Python code in a Jupyter kernel. - Supports authentication with a token or password. - :param jupyter_url: Jupyter server URL (e.g., "http://localhost:8888") - :param code: Code to execute - :param token: Jupyter authentication token (optional) - :param password: Jupyter password (optional) - :param timeout: WebSocket timeout in seconds (default: 10s) - :return: Dictionary with stdout, stderr, and result - - Images are prefixed with "base64:image/png," and separated by newlines if multiple. + Execute Code Result Model """ - session = requests.Session() # Maintain cookies - headers = {} # Headers for requests - # Authenticate using password - if password and not token: + stdout: Optional[str] = "" + stderr: Optional[str] = "" + result: Optional[str] = "" + + +class JupyterCodeExecuter: + """ + Execute code in jupyter notebook + """ + + def __init__( + self, + base_url: str, + code: str, + token: str = "", + password: str = "", + timeout: int = 60, + ): + """ + :param base_url: Jupyter server URL (e.g., "http://localhost:8888") + :param code: Code to execute + :param token: Jupyter authentication token (optional) + :param password: Jupyter password (optional) + :param timeout: WebSocket timeout in seconds (default: 60s) + """ + self.base_url = base_url.rstrip("/") + self.code = code + self.token = token + self.password = password + self.timeout = timeout + self.kernel_id = "" + self.session = aiohttp.ClientSession(base_url=self.base_url) + self.params = {} + self.result = ResultModel() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.kernel_id: + try: + async with self.session.delete( + f"/api/kernels/{self.kernel_id}", params=self.params + ) as response: + response.raise_for_status() + except Exception as err: + logger.exception("close kernel failed, %s", err) + await self.session.close() + + async def run(self) -> ResultModel: try: - login_url = urljoin(jupyter_url, "/login") - response = session.get(login_url) + await self.sign_in() + await self.init_kernel() + await self.execute_code() + except Exception as err: + logger.exception("execute code failed, %s", err) + self.result.stderr = f"Error: {err}" + return self.result + + async def sign_in(self) -> None: + # password authentication + if self.password and not self.token: + async with self.session.get("/login") as response: + response.raise_for_status() + xsrf_token = response.cookies["_xsrf"].value + if not xsrf_token: + raise ValueError("_xsrf token not found") + self.session.cookie_jar.update_cookies(response.cookies) + self.session.headers.update({"X-XSRFToken": xsrf_token}) + async with self.session.post( + "/login", + data={"_xsrf": xsrf_token, "password": self.password}, + allow_redirects=False, + ) as response: + response.raise_for_status() + self.session.cookie_jar.update_cookies(response.cookies) + + # token authentication + if self.token: + self.params.update({"token": self.token}) + + async def init_kernel(self) -> None: + async with self.session.post( + url="/api/kernels", params=self.params + ) as response: response.raise_for_status() - xsrf_token = session.cookies.get("_xsrf") - if not xsrf_token: - raise ValueError("Failed to fetch _xsrf token") - - login_data = {"_xsrf": xsrf_token, "password": password} - login_response = session.post( - login_url, data=login_data, cookies=session.cookies - ) - login_response.raise_for_status() - headers["X-XSRFToken"] = xsrf_token - except Exception as e: - return { - "stdout": "", - "stderr": f"Authentication Error: {str(e)}", - "result": "", - } - - # Construct API URLs with authentication token if provided - params = f"?token={token}" if token else "" - kernel_url = urljoin(jupyter_url, f"/api/kernels{params}") - - try: - response = session.post(kernel_url, headers=headers, cookies=session.cookies) - response.raise_for_status() - kernel_id = response.json()["id"] - - websocket_url = urljoin( - jupyter_url.replace("http", "ws"), - f"/api/kernels/{kernel_id}/channels{params}", - ) + kernel_data = await response.json() + self.kernel_id = kernel_data["id"] + def init_ws(self) -> (str, dict): + ws_base = self.base_url.replace("http", "ws") + ws_params = "?" + "&".join([f"{key}={val}" for key, val in self.params.items()]) + websocket_url = f"{ws_base}/api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ''}" ws_headers = {} - if password and not token: - ws_headers["X-XSRFToken"] = session.cookies.get("_xsrf") - cookies = {name: value for name, value in session.cookies.items()} - ws_headers["Cookie"] = "; ".join( - [f"{name}={value}" for name, value in cookies.items()] - ) + if self.password and not self.token: + ws_headers = { + "Cookie": "; ".join( + [ + f"{cookie.key}={cookie.value}" + for cookie in self.session.cookie_jar + ] + ), + **self.session.headers, + } + return websocket_url, ws_headers + async def execute_code(self) -> None: + # initialize ws + websocket_url, ws_headers = self.init_ws() + # execute async with websockets.connect( websocket_url, additional_headers=ws_headers ) as ws: - msg_id = str(uuid.uuid4()) - execute_request = { - "header": { - "msg_id": msg_id, - "msg_type": "execute_request", - "username": "user", - "session": str(uuid.uuid4()), - "date": "", - "version": "5.3", - }, - "parent_header": {}, - "metadata": {}, - "content": { - "code": code, - "silent": False, - "store_history": True, - "user_expressions": {}, - "allow_stdin": False, - "stop_on_error": True, - }, - "channel": "shell", - } - await ws.send(json.dumps(execute_request)) + await self.execute_in_jupyter(ws) - stdout, stderr, result = "", "", [] - - while True: - try: - message = await asyncio.wait_for(ws.recv(), timeout) - message_data = json.loads(message) - if message_data.get("parent_header", {}).get("msg_id") == msg_id: - msg_type = message_data.get("msg_type") - - if msg_type == "stream": - if message_data["content"]["name"] == "stdout": - stdout += message_data["content"]["text"] - elif message_data["content"]["name"] == "stderr": - stderr += message_data["content"]["text"] - - elif msg_type in ("execute_result", "display_data"): - data = message_data["content"]["data"] - if "image/png" in data: - result.append( - f"data:image/png;base64,{data['image/png']}" - ) - elif "text/plain" in data: - result.append(data["text/plain"]) - - elif msg_type == "error": - stderr += "\n".join(message_data["content"]["traceback"]) - - elif ( - msg_type == "status" - and message_data["content"]["execution_state"] == "idle" - ): + async def execute_in_jupyter(self, ws) -> None: + # send message + msg_id = uuid.uuid4().hex + await ws.send( + json.dumps( + { + "header": { + "msg_id": msg_id, + "msg_type": "execute_request", + "username": "user", + "session": uuid.uuid4().hex, + "date": "", + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": self.code, + "silent": False, + "store_history": True, + "user_expressions": {}, + "allow_stdin": False, + "stop_on_error": True, + }, + "channel": "shell", + } + ) + ) + # parse message + stdout, stderr, result = "", "", [] + while True: + try: + # wait for message + message = await asyncio.wait_for(ws.recv(), self.timeout) + message_data = json.loads(message) + # msg id not match, skip + if message_data.get("parent_header", {}).get("msg_id") != msg_id: + continue + # check message type + msg_type = message_data.get("msg_type") + match msg_type: + case "stream": + if message_data["content"]["name"] == "stdout": + stdout += message_data["content"]["text"] + elif message_data["content"]["name"] == "stderr": + stderr += message_data["content"]["text"] + case "execute_result" | "display_data": + data = message_data["content"]["data"] + if "image/png" in data: + result.append(f"data:image/png;base64,{data['image/png']}") + elif "text/plain" in data: + result.append(data["text/plain"]) + case "error": + stderr += "\n".join(message_data["content"]["traceback"]) + case "status": + if message_data["content"]["execution_state"] == "idle": break - except asyncio.TimeoutError: - stderr += "\nExecution timed out." - break + except asyncio.TimeoutError: + stderr += "\nExecution timed out." + break + self.result.stdout = stdout.strip() + self.result.stderr = stderr.strip() + self.result.result = "\n".join(result).strip() if result else "" - except Exception as e: - return {"stdout": "", "stderr": f"Error: {str(e)}", "result": ""} - finally: - if kernel_id: - requests.delete( - f"{kernel_url}/{kernel_id}", headers=headers, cookies=session.cookies - ) - - return { - "stdout": stdout.strip(), - "stderr": stderr.strip(), - "result": "\n".join(result).strip() if result else "", - } +async def execute_code_jupyter( + base_url: str, code: str, token: str = "", password: str = "", timeout: int = 60 +) -> dict: + async with JupyterCodeExecuter( + base_url, code, token, password, timeout + ) as executor: + result = await executor.run() + return result.model_dump() diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 0ca754ed8..0edc2ac70 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -9,7 +9,7 @@ log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MAIN"]) -def get_sorted_filter_ids(model): +def get_sorted_filter_ids(model: dict): def get_priority(function_id): function = Functions.get_function_by_id(function_id) if function is not None and hasattr(function, "valves"): @@ -33,12 +33,13 @@ def get_sorted_filter_ids(model): async def process_filter_functions( - request, filter_ids, filter_type, form_data, extra_params + request, filter_functions, filter_type, form_data, extra_params ): skip_files = None - for filter_id in filter_ids: - filter = Functions.get_function_by_id(filter_id) + for function in filter_functions: + filter = function + filter_id = function.id if not filter: continue @@ -48,6 +49,11 @@ async def process_filter_functions( function_module, _, _ = load_function_module_by_id(filter_id) request.app.state.FUNCTIONS[filter_id] = function_module + # Prepare handler function + handler = getattr(function_module, filter_type, None) + if not handler: + continue + # Check if the function has a file_handler variable if filter_type == "inlet" and hasattr(function_module, "file_handler"): skip_files = function_module.file_handler @@ -59,11 +65,6 @@ async def process_filter_functions( **(valves if valves else {}) ) - # Prepare handler function - handler = getattr(function_module, filter_type, None) - if not handler: - continue - try: # Prepare parameters sig = inspect.signature(handler) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 43fd0d480..289d887df 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -68,6 +68,7 @@ from open_webui.utils.misc import ( get_last_user_message, get_last_assistant_message, prepend_to_first_user_message_content, + convert_logit_bias_input_to_json, ) from open_webui.utils.tools import get_tools from open_webui.utils.plugin import load_function_module_by_id @@ -610,11 +611,18 @@ def apply_params_to_form_data(form_data, model): if "reasoning_effort" in params: form_data["reasoning_effort"] = params["reasoning_effort"] + if "logit_bias" in params: + try: + form_data["logit_bias"] = json.loads( + convert_logit_bias_input_to_json(params["logit_bias"]) + ) + except Exception as e: + print(f"Error parsing logit_bias: {e}") return form_data -async def process_chat_payload(request, form_data, metadata, user, model): +async def process_chat_payload(request, form_data, user, metadata, model): form_data = apply_params_to_form_data(form_data, model) log.debug(f"form_data: {form_data}") @@ -707,9 +715,14 @@ async def process_chat_payload(request, form_data, metadata, user, model): raise e try: + filter_functions = [ + Functions.get_function_by_id(filter_id) + for filter_id in get_sorted_filter_ids(model) + ] + form_data, flags = await process_filter_functions( request=request, - filter_ids=get_sorted_filter_ids(model), + filter_functions=filter_functions, filter_type="inlet", form_data=form_data, extra_params=extra_params, @@ -856,7 +869,7 @@ async def process_chat_payload(request, form_data, metadata, user, model): async def process_chat_response( - request, response, form_data, user, events, metadata, tasks + request, response, form_data, user, metadata, model, events, tasks ): async def background_tasks_handler(): message_map = Chats.get_messages_by_chat_id(metadata["chat_id"]) @@ -1061,9 +1074,14 @@ async def process_chat_response( }, "__metadata__": metadata, "__request__": request, - "__model__": metadata.get("model"), + "__model__": model, } - filter_ids = get_sorted_filter_ids(form_data.get("model")) + filter_functions = [ + Functions.get_function_by_id(filter_id) + for filter_id in get_sorted_filter_ids(model) + ] + + print(f"{filter_functions=}") # Streaming response if event_emitter and event_caller: @@ -1470,7 +1488,7 @@ async def process_chat_response( data, _ = await process_filter_functions( request=request, - filter_ids=filter_ids, + filter_functions=filter_functions, filter_type="stream", form_data=data, extra_params=extra_params, @@ -1544,9 +1562,59 @@ async def process_chat_response( value = delta.get("content") - if value: - content = f"{content}{value}" + reasoning_content = delta.get("reasoning_content") + if reasoning_content: + if ( + not content_blocks + or content_blocks[-1]["type"] != "reasoning" + ): + reasoning_block = { + "type": "reasoning", + "start_tag": "think", + "end_tag": "/think", + "attributes": { + "type": "reasoning_content" + }, + "content": "", + "started_at": time.time(), + } + content_blocks.append(reasoning_block) + else: + reasoning_block = content_blocks[-1] + reasoning_block["content"] += reasoning_content + + data = { + "content": serialize_content_blocks( + content_blocks + ) + } + + if value: + if ( + content_blocks + and content_blocks[-1]["type"] + == "reasoning" + and content_blocks[-1] + .get("attributes", {}) + .get("type") + == "reasoning_content" + ): + reasoning_block = content_blocks[-1] + reasoning_block["ended_at"] = time.time() + reasoning_block["duration"] = int( + reasoning_block["ended_at"] + - reasoning_block["started_at"] + ) + + content_blocks.append( + { + "type": "text", + "content": "", + } + ) + + content = f"{content}{value}" if not content_blocks: content_blocks.append( { @@ -2017,7 +2085,7 @@ async def process_chat_response( for event in events: event, _ = await process_filter_functions( request=request, - filter_ids=filter_ids, + filter_functions=filter_functions, filter_type="stream", form_data=event, extra_params=extra_params, @@ -2029,7 +2097,7 @@ async def process_chat_response( async for data in original_generator: data, _ = await process_filter_functions( request=request, - filter_ids=filter_ids, + filter_functions=filter_functions, filter_type="stream", form_data=data, extra_params=extra_params, diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 8f867bace..98938dfea 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -6,6 +6,7 @@ import logging from datetime import timedelta from pathlib import Path from typing import Callable, Optional +import json import collections.abc @@ -450,3 +451,15 @@ def parse_ollama_modelfile(model_text): data["params"]["messages"] = messages return data + + +def convert_logit_bias_input_to_json(user_input): + logit_bias_pairs = user_input.split(",") + logit_bias_json = {} + for pair in logit_bias_pairs: + token, bias = pair.split(":") + token = str(token.strip()) + bias = int(bias.strip()) + bias = 100 if bias > 100 else -100 if bias < -100 else bias + logit_bias_json[token] = bias + return json.dumps(logit_bias_json) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 2af54c19d..be3466362 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -234,7 +234,7 @@ class OAuthManager: log.warning(f"OAuth callback error: {e}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) user_data: UserInfo = token.get("userinfo") - if not user_data or "email" not in user_data: + if not user_data or auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data: user_data: UserInfo = await client.userinfo(token=token) if not user_data: log.warning(f"OAuth callback failed, user data is missing: {token}") diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 869e70895..46656cc82 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -62,6 +62,7 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict: "reasoning_effort": str, "seed": lambda x: x, "stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x], + "logit_bias": lambda x: x, } return apply_model_params_to_body(params, form_data, mappings) diff --git a/backend/open_webui/utils/pdf_generator.py b/backend/open_webui/utils/pdf_generator.py index 8b04dd81b..c137b49da 100644 --- a/backend/open_webui/utils/pdf_generator.py +++ b/backend/open_webui/utils/pdf_generator.py @@ -110,7 +110,7 @@ class PDFGenerator: # When running using `pip install -e .` the static directory is in the site packages. # This path only works if `open-webui serve` is run from the root of this project. if not FONTS_DIR.exists(): - FONTS_DIR = Path("./backend/static/fonts") + FONTS_DIR = Path(".") / "backend" / "static" / "fonts" pdf.add_font("NotoSans", "", f"{FONTS_DIR}/NotoSans-Regular.ttf") pdf.add_font("NotoSans", "b", f"{FONTS_DIR}/NotoSans-Bold.ttf") diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index 5663ce2ac..3a8b4b0a4 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -104,7 +104,7 @@ def replace_prompt_variable(template: str, prompt: str) -> str: def replace_messages_variable( - template: str, messages: Optional[list[str]] = None + template: str, messages: Optional[list[dict]] = None ) -> str: def replacement_function(match): full_match = match.group(0) diff --git a/backend/requirements.txt b/backend/requirements.txt index 616827144..eb1ee6018 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,5 @@ fastapi==0.115.7 -uvicorn[standard]==0.30.6 +uvicorn[standard]==0.34.0 pydantic==2.10.6 python-multipart==0.0.18 @@ -13,14 +13,14 @@ async-timeout aiocache aiofiles -sqlalchemy==2.0.32 +sqlalchemy==2.0.38 alembic==1.14.0 -peewee==3.17.8 +peewee==3.17.9 peewee-migrate==1.12.2 psycopg2-binary==2.9.9 pgvector==0.3.5 PyMySQL==1.1.1 -bcrypt==4.2.0 +bcrypt==4.3.0 pymongo redis @@ -40,8 +40,8 @@ anthropic google-generativeai==0.7.2 tiktoken -langchain==0.3.7 -langchain-community==0.3.7 +langchain==0.3.19 +langchain-community==0.3.18 fake-useragent==1.5.1 chromadb==0.6.2 @@ -49,6 +49,8 @@ pymilvus==2.5.0 qdrant-client~=1.12.0 opensearch-py==2.8.0 playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml +elasticsearch==8.17.1 + transformers sentence-transformers==3.3.1 @@ -85,7 +87,7 @@ faster-whisper==1.1.1 PyJWT[crypto]==2.10.1 authlib==1.4.1 -black==24.8.0 +black==25.1.0 langfuse==2.44.0 youtube-transcript-api==0.6.3 pytube==15.0.0 diff --git a/package-lock.json b/package-lock.json index e48c7930e..719e8718d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "open-webui", - "version": "0.5.18", + "version": "0.5.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.5.18", + "version": "0.5.19", "dependencies": { + "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", "@codemirror/language-data": "^6.5.1", @@ -135,6 +136,27 @@ "node": ">=6.0.0" } }, + "node_modules/@azure/msal-browser": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.5.0.tgz", + "integrity": "sha512-H7mWmu8yI0n0XxhJobrgncXI6IU5h8DKMiWDHL5y+Dc58cdg26GbmaMUehbUkdKAQV2OTiFa4FUa6Fdu/wIxBg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.2.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.2.0.tgz", + "integrity": "sha512-HiYfGAKthisUYqHG1nImCf/uzcyS31wng3o+CycWLIM9chnYJ9Lk6jZ30Y6YiYYpTQ9+z/FGUpiKKekd3Arc0A==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@babel/runtime": { "version": "7.26.9", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", diff --git a/package.json b/package.json index 1d2e86741..63d7a49c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.5.18", + "version": "0.5.19", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -51,6 +51,7 @@ }, "type": "module", "dependencies": { + "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", "@codemirror/language-data": "^6.5.1", diff --git a/pyproject.toml b/pyproject.toml index ccf486346..0666ac8a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ license = { file = "LICENSE" } dependencies = [ "fastapi==0.115.7", - "uvicorn[standard]==0.30.6", + "uvicorn[standard]==0.34.0", "pydantic==2.10.6", "python-multipart==0.0.18", @@ -21,14 +21,14 @@ dependencies = [ "aiocache", "aiofiles", - "sqlalchemy==2.0.32", + "sqlalchemy==2.0.38", "alembic==1.14.0", - "peewee==3.17.8", + "peewee==3.17.9", "peewee-migrate==1.12.2", "psycopg2-binary==2.9.9", "pgvector==0.3.5", "PyMySQL==1.1.1", - "bcrypt==4.2.0", + "bcrypt==4.3.0", "pymongo", "redis", @@ -48,8 +48,8 @@ dependencies = [ "google-generativeai==0.7.2", "tiktoken", - "langchain==0.3.7", - "langchain-community==0.3.7", + "langchain==0.3.19", + "langchain-community==0.3.18", "fake-useragent==1.5.1", "chromadb==0.6.2", @@ -57,6 +57,7 @@ dependencies = [ "qdrant-client~=1.12.0", "opensearch-py==2.8.0", "playwright==1.49.1", + "elasticsearch==8.17.1", "transformers", "sentence-transformers==3.3.1", @@ -92,7 +93,7 @@ dependencies = [ "PyJWT[crypto]==2.10.1", "authlib==1.4.1", - "black==24.8.0", + "black==25.1.0", "langfuse==2.44.0", "youtube-transcript-api==0.6.3", "pytube==15.0.0", diff --git a/src/app.html b/src/app.html index c8fbfca72..e64e6d583 100644 --- a/src/app.html +++ b/src/app.html @@ -2,12 +2,14 @@
- - - - + + + + + - + + { + logo.src = '/static/splash-dark.png'; + logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists + }; + + darkImage.onerror = () => { + logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing + }; + } + } + + // Runs after classes are assigned + window.onload = setSplashImage; })(); @@ -176,10 +200,6 @@ background: #000; } - html.dark #splash-screen img { - filter: invert(1); - } - html.her #splash-screen { background: #983724; } diff --git a/src/lib/components/OnBoarding.svelte b/src/lib/components/OnBoarding.svelte index e68a7f2c1..1976e5c6e 100644 --- a/src/lib/components/OnBoarding.svelte +++ b/src/lib/components/OnBoarding.svelte @@ -1,5 +1,5 @@ {#if show} @@ -18,6 +44,7 @@