Merge remote-tracking branch 'origin' into logit_bias

This commit is contained in:
dannyl1u 2025-02-27 23:48:22 -08:00
commit f4bd094128
181 changed files with 10428 additions and 5218 deletions

View file

@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 22
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.11

View file

@ -5,6 +5,87 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.18] - 2025-02-27
### Fixed
- **🌐 Open WebUI Now Works Over LAN in Insecure Context**: Resolved an issue preventing Open WebUI from functioning when accessed over a local network in an insecure context, ensuring seamless connectivity.
- **🔄 UI Now Reflects Deleted Connections Instantly**: Fixed an issue where deleting a connection did not update the UI in real time, ensuring accurate system state visibility.
- **🛠️ Models Now Display Correctly with ENABLE_FORWARD_USER_INFO_HEADERS**: Addressed a bug where models were not visible when ENABLE_FORWARD_USER_INFO_HEADERS was set, restoring proper model listing.
## [0.5.17] - 2025-02-27
### Added
- **🚀 Instant Document Upload with Bypass Embedding & Retrieval**: Admins can now enable "Bypass Embedding & Retrieval" in Admin Settings > Documents, significantly speeding up document uploads and ensuring full document context is retained without chunking.
- **🔎 "Stream" Hook for Real-Time Filtering**: The new "stream" hook allows dynamic real-time message filtering. Learn more in our documentation (https://docs.openwebui.com/features/plugin/functions/filter).
- **☁️ OneDrive Integration**: Early support for OneDrive storage integration has been introduced, expanding file import options.
- **📈 Enhanced Logging with Loguru**: Backend logging has been improved with Loguru, making debugging and issue tracking far more efficient.
- **⚙️ General Stability Enhancements**: Backend and frontend refactoring improves performance, ensuring a smoother and more reliable user experience.
- **🌍 Updated Translations**: Refined multilingual support for better localization and accuracy across various languages.
### Fixed
- **🔄 Reliable Model Imports from the Community Platform**: Resolved import failures, allowing seamless integration of community-shared models without errors.
- **📊 OpenAI Usage Statistics Restored**: Fixed an issue where OpenAI usage metrics were not displaying correctly, ensuring accurate tracking of usage data.
- **🗂️ Deduplication for Retrieved Documents**: Documents retrieved during searches are now intelligently deduplicated, meaning no more redundant results—helping to keep information concise and relevant.
### Changed
- **📝 "Full Context Mode" Renamed for Clarity**: The "Full Context Mode" toggle in Web Search settings is now labeled "Bypass Embedding & Retrieval" for consistency across the UI.
## [0.5.16] - 2025-02-20
### Fixed
- **🔍 Web Search Retrieval Restored**: Resolved a critical issue that broke web search retrieval by reverting deduplication changes, ensuring complete and accurate search results once again.
## [0.5.15] - 2025-02-20
### Added
- **📄 Full Context Mode for Local Document Search (RAG)**: Toggle full context mode from Admin Settings > Documents to inject entire document content into context, improving accuracy for models with large context windows—ideal for deep context understanding.
- **🌍 Smarter Web Search with Agentic Workflows**: Web searches now intelligently gather and refine multiple relevant terms, similar to RAG handling, delivering significantly better search results for more accurate information retrieval.
- **🔎 Experimental Playwright Support for Web Loader**: Web content retrieval is taken to the next level with Playwright-powered scraping for enhanced accuracy in extracted web data.
- **☁️ Experimental Azure Storage Provider**: Early-stage support for Azure Storage allows more cloud storage flexibility directly within Open WebUI.
- **📊 Improved Jupyter Code Execution with Plots**: Interactive coding now properly displays inline plots, making data visualization more seamless inside chat interactions.
- **⏳ Adjustable Execution Timeout for Jupyter Interpreter**: Customize execution timeout (default: 60s) for Jupyter-based code execution, allowing longer or more constrained execution based on your needs.
- **▶️ "Running..." Indicator for Jupyter Code Execution**: A visual indicator now appears while code execution is in progress, providing real-time status updates on ongoing computations.
- **⚙️ General Backend & Frontend Stability Enhancements**: Extensive refactoring improves reliability, performance, and overall user experience for a more seamless Open WebUI.
- **🌍 Translation Updates**: Various international translation refinements ensure better localization and a more natural user interface experience.
### Fixed
- **📱 Mobile Hover Issue Resolved**: Users can now edit responses smoothly on mobile without interference, fixing a longstanding hover issue.
- **🔄 Temporary Chat Message Duplication Fixed**: Eliminated buggy behavior where messages were being unnecessarily repeated in temporary chat mode, ensuring a smooth and consistent conversation flow.
## [0.5.14] - 2025-02-17
### Fixed
- **🔧 Critical Import Error Resolved**: Fixed a circular import issue preventing 'override_static' from being correctly imported in 'open_webui.config', ensuring smooth system initialization and stability.
## [0.5.13] - 2025-02-17
### Added
- **🌐 Full Context Mode for Web Search**: Enable highly accurate web searches by utilizing full context mode—ideal for models with large context windows, ensuring more precise and insightful results.
- **⚡ Optimized Asynchronous Web Search**: Web searches now load significantly faster with optimized async support, providing users with quicker, more efficient information retrieval.
- **🔄 Auto Text Direction for RTL Languages**: Automatic text alignment based on language input, ensuring seamless conversation flow for Arabic, Hebrew, and other right-to-left scripts.
- **🚀 Jupyter Notebook Support for Code Execution**: The "Run" button in code blocks can now use Jupyter for execution, offering a powerful, dynamic coding experience directly in the chat.
- **🗑️ Message Delete Confirmation Dialog**: Prevent accidental deletions with a new confirmation prompt before removing messages, adding an additional layer of security to your chat history.
- **📥 Download Button for SVG Diagrams**: SVG diagrams generated within chat can now be downloaded instantly, making it easier to save and share complex visual data.
- **✨ General UI/UX Improvements and Backend Stability**: A refined interface with smoother interactions, improved layouts, and backend stability enhancements for a more reliable, polished experience.
### Fixed
- **🛠️ Temporary Chat Message Continue Button Fixed**: The "Continue Response" button for temporary chats now works as expected, ensuring an uninterrupted conversation flow.
### Changed
- **📝 Prompt Variable Update**: Deprecated square bracket '[]' indicators for prompt variables; now requires double curly brackets '{{}}' for consistency and clarity.
- **🔧 Stability Enhancements**: Error handling improved in chat history, ensuring smoother operations when reviewing previous messages.
## [0.5.12] - 2025-02-13 ## [0.5.12] - 2025-02-13
### Added ### Added

View file

@ -13,10 +13,15 @@
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**. **Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
![Open WebUI Demo](./demo.gif) ![Open WebUI Demo](./demo.gif)
> [!TIP]
> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** **[Speak with Our Sales Team Today!](mailto:sales@openwebui.com)**
>
> Get **enhanced capabilities**, including **custom theming and branding**, **Service Level Agreement (SLA) support**, **Long-Term Support (LTS) versions**, and **more!**
For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
## Key Features of Open WebUI ⭐ ## Key Features of Open WebUI ⭐
- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images. - 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images.

View file

@ -9,7 +9,6 @@ from pathlib import Path
from typing import Generic, Optional, TypeVar from typing import Generic, Optional, TypeVar
from urllib.parse import urlparse from urllib.parse import urlparse
import chromadb
import requests import requests
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import JSON, Column, DateTime, Integer, func from sqlalchemy import JSON, Column, DateTime, Integer, func
@ -44,7 +43,7 @@ logging.getLogger("uvicorn.access").addFilter(EndpointFilter())
# Function to run the alembic migrations # Function to run the alembic migrations
def run_migrations(): def run_migrations():
print("Running migrations") log.info("Running migrations")
try: try:
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
@ -57,7 +56,7 @@ def run_migrations():
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
except Exception as e: except Exception as e:
print(f"Error: {e}") log.exception(f"Error running migrations: {e}")
run_migrations() run_migrations()
@ -588,20 +587,6 @@ load_oauth_providers()
STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")).resolve() STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")).resolve()
def override_static(path: str, content: str):
# Ensure path is safe
if "/" in path or ".." in path:
log.error(f"Invalid path: {path}")
return
file_path = os.path.join(STATIC_DIR, path)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb") as f:
f.write(base64.b64decode(content)) # Convert Base64 back to raw binary
frontend_favicon = FRONTEND_BUILD_DIR / "static" / "favicon.png" frontend_favicon = FRONTEND_BUILD_DIR / "static" / "favicon.png"
if frontend_favicon.exists(): if frontend_favicon.exists():
@ -692,12 +677,20 @@ S3_REGION_NAME = os.environ.get("S3_REGION_NAME", None)
S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None) S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None)
S3_KEY_PREFIX = os.environ.get("S3_KEY_PREFIX", None) S3_KEY_PREFIX = os.environ.get("S3_KEY_PREFIX", None)
S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None) S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None)
S3_USE_ACCELERATE_ENDPOINT = (
os.environ.get("S3_USE_ACCELERATE_ENDPOINT", "False").lower() == "true"
)
S3_ADDRESSING_STYLE = os.environ.get("S3_ADDRESSING_STYLE", None)
GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None) GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None)
GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get( GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get(
"GOOGLE_APPLICATION_CREDENTIALS_JSON", None "GOOGLE_APPLICATION_CREDENTIALS_JSON", None
) )
AZURE_STORAGE_ENDPOINT = os.environ.get("AZURE_STORAGE_ENDPOINT", None)
AZURE_STORAGE_CONTAINER_NAME = os.environ.get("AZURE_STORAGE_CONTAINER_NAME", None)
AZURE_STORAGE_KEY = os.environ.get("AZURE_STORAGE_KEY", None)
#################################### ####################################
# File Upload DIR # File Upload DIR
#################################### ####################################
@ -797,6 +790,9 @@ ENABLE_OPENAI_API = PersistentConfig(
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
OPENAI_API_BASE_URL = os.environ.get("OPENAI_API_BASE_URL", "") OPENAI_API_BASE_URL = os.environ.get("OPENAI_API_BASE_URL", "")
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
GEMINI_API_BASE_URL = os.environ.get("GEMINI_API_BASE_URL", "")
if OPENAI_API_BASE_URL == "": if OPENAI_API_BASE_URL == "":
OPENAI_API_BASE_URL = "https://api.openai.com/v1" OPENAI_API_BASE_URL = "https://api.openai.com/v1"
@ -1101,7 +1097,7 @@ try:
banners = json.loads(os.environ.get("WEBUI_BANNERS", "[]")) banners = json.loads(os.environ.get("WEBUI_BANNERS", "[]"))
banners = [BannerModel(**banner) for banner in banners] banners = [BannerModel(**banner) for banner in banners]
except Exception as e: except Exception as e:
print(f"Error loading WEBUI_BANNERS: {e}") log.exception(f"Error loading WEBUI_BANNERS: {e}")
banners = [] banners = []
WEBUI_BANNERS = PersistentConfig("WEBUI_BANNERS", "ui.banners", banners) WEBUI_BANNERS = PersistentConfig("WEBUI_BANNERS", "ui.banners", banners)
@ -1377,6 +1373,44 @@ Responses from models: {{responses}}"""
# Code Interpreter # Code Interpreter
#################################### ####################################
CODE_EXECUTION_ENGINE = PersistentConfig(
"CODE_EXECUTION_ENGINE",
"code_execution.engine",
os.environ.get("CODE_EXECUTION_ENGINE", "pyodide"),
)
CODE_EXECUTION_JUPYTER_URL = PersistentConfig(
"CODE_EXECUTION_JUPYTER_URL",
"code_execution.jupyter.url",
os.environ.get("CODE_EXECUTION_JUPYTER_URL", ""),
)
CODE_EXECUTION_JUPYTER_AUTH = PersistentConfig(
"CODE_EXECUTION_JUPYTER_AUTH",
"code_execution.jupyter.auth",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH", ""),
)
CODE_EXECUTION_JUPYTER_AUTH_TOKEN = PersistentConfig(
"CODE_EXECUTION_JUPYTER_AUTH_TOKEN",
"code_execution.jupyter.auth_token",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_TOKEN", ""),
)
CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = PersistentConfig(
"CODE_EXECUTION_JUPYTER_AUTH_PASSWORD",
"code_execution.jupyter.auth_password",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", ""),
)
CODE_EXECUTION_JUPYTER_TIMEOUT = PersistentConfig(
"CODE_EXECUTION_JUPYTER_TIMEOUT",
"code_execution.jupyter.timeout",
int(os.environ.get("CODE_EXECUTION_JUPYTER_TIMEOUT", "60")),
)
ENABLE_CODE_INTERPRETER = PersistentConfig( ENABLE_CODE_INTERPRETER = PersistentConfig(
"ENABLE_CODE_INTERPRETER", "ENABLE_CODE_INTERPRETER",
"code_interpreter.enable", "code_interpreter.enable",
@ -1398,26 +1432,48 @@ CODE_INTERPRETER_PROMPT_TEMPLATE = PersistentConfig(
CODE_INTERPRETER_JUPYTER_URL = PersistentConfig( CODE_INTERPRETER_JUPYTER_URL = PersistentConfig(
"CODE_INTERPRETER_JUPYTER_URL", "CODE_INTERPRETER_JUPYTER_URL",
"code_interpreter.jupyter.url", "code_interpreter.jupyter.url",
os.environ.get("CODE_INTERPRETER_JUPYTER_URL", ""), os.environ.get(
"CODE_INTERPRETER_JUPYTER_URL", os.environ.get("CODE_EXECUTION_JUPYTER_URL", "")
),
) )
CODE_INTERPRETER_JUPYTER_AUTH = PersistentConfig( CODE_INTERPRETER_JUPYTER_AUTH = PersistentConfig(
"CODE_INTERPRETER_JUPYTER_AUTH", "CODE_INTERPRETER_JUPYTER_AUTH",
"code_interpreter.jupyter.auth", "code_interpreter.jupyter.auth",
os.environ.get("CODE_INTERPRETER_JUPYTER_AUTH", ""), os.environ.get(
"CODE_INTERPRETER_JUPYTER_AUTH",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH", ""),
),
) )
CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = PersistentConfig( CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = PersistentConfig(
"CODE_INTERPRETER_JUPYTER_AUTH_TOKEN", "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN",
"code_interpreter.jupyter.auth_token", "code_interpreter.jupyter.auth_token",
os.environ.get("CODE_INTERPRETER_JUPYTER_AUTH_TOKEN", ""), os.environ.get(
"CODE_INTERPRETER_JUPYTER_AUTH_TOKEN",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_TOKEN", ""),
),
) )
CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = PersistentConfig( CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = PersistentConfig(
"CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD", "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD",
"code_interpreter.jupyter.auth_password", "code_interpreter.jupyter.auth_password",
os.environ.get("CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD", ""), os.environ.get(
"CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", ""),
),
)
CODE_INTERPRETER_JUPYTER_TIMEOUT = PersistentConfig(
"CODE_INTERPRETER_JUPYTER_TIMEOUT",
"code_interpreter.jupyter.timeout",
int(
os.environ.get(
"CODE_INTERPRETER_JUPYTER_TIMEOUT",
os.environ.get("CODE_EXECUTION_JUPYTER_TIMEOUT", "60"),
)
),
) )
@ -1445,21 +1501,27 @@ VECTOR_DB = os.environ.get("VECTOR_DB", "chroma")
# Chroma # Chroma
CHROMA_DATA_PATH = f"{DATA_DIR}/vector_db" CHROMA_DATA_PATH = f"{DATA_DIR}/vector_db"
CHROMA_TENANT = os.environ.get("CHROMA_TENANT", chromadb.DEFAULT_TENANT)
CHROMA_DATABASE = os.environ.get("CHROMA_DATABASE", chromadb.DEFAULT_DATABASE) if VECTOR_DB == "chroma":
CHROMA_HTTP_HOST = os.environ.get("CHROMA_HTTP_HOST", "") import chromadb
CHROMA_HTTP_PORT = int(os.environ.get("CHROMA_HTTP_PORT", "8000"))
CHROMA_CLIENT_AUTH_PROVIDER = os.environ.get("CHROMA_CLIENT_AUTH_PROVIDER", "") CHROMA_TENANT = os.environ.get("CHROMA_TENANT", chromadb.DEFAULT_TENANT)
CHROMA_CLIENT_AUTH_CREDENTIALS = os.environ.get("CHROMA_CLIENT_AUTH_CREDENTIALS", "") CHROMA_DATABASE = os.environ.get("CHROMA_DATABASE", chromadb.DEFAULT_DATABASE)
# Comma-separated list of header=value pairs CHROMA_HTTP_HOST = os.environ.get("CHROMA_HTTP_HOST", "")
CHROMA_HTTP_HEADERS = os.environ.get("CHROMA_HTTP_HEADERS", "") CHROMA_HTTP_PORT = int(os.environ.get("CHROMA_HTTP_PORT", "8000"))
if CHROMA_HTTP_HEADERS: CHROMA_CLIENT_AUTH_PROVIDER = os.environ.get("CHROMA_CLIENT_AUTH_PROVIDER", "")
CHROMA_CLIENT_AUTH_CREDENTIALS = os.environ.get(
"CHROMA_CLIENT_AUTH_CREDENTIALS", ""
)
# Comma-separated list of header=value pairs
CHROMA_HTTP_HEADERS = os.environ.get("CHROMA_HTTP_HEADERS", "")
if CHROMA_HTTP_HEADERS:
CHROMA_HTTP_HEADERS = dict( CHROMA_HTTP_HEADERS = dict(
[pair.split("=") for pair in CHROMA_HTTP_HEADERS.split(",")] [pair.split("=") for pair in CHROMA_HTTP_HEADERS.split(",")]
) )
else: else:
CHROMA_HTTP_HEADERS = None CHROMA_HTTP_HEADERS = None
CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true" CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true"
# this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2) # this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2)
# Milvus # Milvus
@ -1513,6 +1575,18 @@ GOOGLE_DRIVE_API_KEY = PersistentConfig(
os.environ.get("GOOGLE_DRIVE_API_KEY", ""), os.environ.get("GOOGLE_DRIVE_API_KEY", ""),
) )
ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
"ENABLE_ONEDRIVE_INTEGRATION",
"onedrive.enable",
os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
)
ONEDRIVE_CLIENT_ID = PersistentConfig(
"ONEDRIVE_CLIENT_ID",
"onedrive.client_id",
os.environ.get("ONEDRIVE_CLIENT_ID", ""),
)
# RAG Content Extraction # RAG Content Extraction
CONTENT_EXTRACTION_ENGINE = PersistentConfig( CONTENT_EXTRACTION_ENGINE = PersistentConfig(
"CONTENT_EXTRACTION_ENGINE", "CONTENT_EXTRACTION_ENGINE",
@ -1526,6 +1600,26 @@ TIKA_SERVER_URL = PersistentConfig(
os.getenv("TIKA_SERVER_URL", "http://tika:9998"), # Default for sidecar deployment os.getenv("TIKA_SERVER_URL", "http://tika:9998"), # Default for sidecar deployment
) )
DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig(
"DOCUMENT_INTELLIGENCE_ENDPOINT",
"rag.document_intelligence_endpoint",
os.getenv("DOCUMENT_INTELLIGENCE_ENDPOINT", ""),
)
DOCUMENT_INTELLIGENCE_KEY = PersistentConfig(
"DOCUMENT_INTELLIGENCE_KEY",
"rag.document_intelligence_key",
os.getenv("DOCUMENT_INTELLIGENCE_KEY", ""),
)
BYPASS_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
"BYPASS_EMBEDDING_AND_RETRIEVAL",
"rag.bypass_embedding_and_retrieval",
os.environ.get("BYPASS_EMBEDDING_AND_RETRIEVAL", "False").lower() == "true",
)
RAG_TOP_K = PersistentConfig( RAG_TOP_K = PersistentConfig(
"RAG_TOP_K", "rag.top_k", int(os.environ.get("RAG_TOP_K", "3")) "RAG_TOP_K", "rag.top_k", int(os.environ.get("RAG_TOP_K", "3"))
) )
@ -1541,6 +1635,12 @@ ENABLE_RAG_HYBRID_SEARCH = PersistentConfig(
os.environ.get("ENABLE_RAG_HYBRID_SEARCH", "").lower() == "true", os.environ.get("ENABLE_RAG_HYBRID_SEARCH", "").lower() == "true",
) )
RAG_FULL_CONTEXT = PersistentConfig(
"RAG_FULL_CONTEXT",
"rag.full_context",
os.getenv("RAG_FULL_CONTEXT", "False").lower() == "true",
)
RAG_FILE_MAX_COUNT = PersistentConfig( RAG_FILE_MAX_COUNT = PersistentConfig(
"RAG_FILE_MAX_COUNT", "RAG_FILE_MAX_COUNT",
"rag.file.max_count", "rag.file.max_count",
@ -1655,7 +1755,7 @@ Respond to the user query using the provided context, incorporating inline citat
- Respond in the same language as the user's query. - Respond in the same language as the user's query.
- If the context is unreadable or of poor quality, inform the user and provide the best possible answer. - If the context is unreadable or of poor quality, inform the user and provide the best possible answer.
- If the answer isn't present in the context but you possess the knowledge, explain this to the user and provide the answer using your own understanding. - If the answer isn't present in the context but you possess the knowledge, explain this to the user and provide the answer using your own understanding.
- **Only include inline citations using [source_id] when a <source_id> tag is explicitly provided in the context.** - **Only include inline citations using [source_id] (e.g., [1], [2]) when a `<source_id>` tag is explicitly provided in the context.**
- Do not cite if the <source_id> tag is not provided in the context. - Do not cite if the <source_id> tag is not provided in the context.
- Do not use XML tags in your response. - Do not use XML tags in your response.
- Ensure citations are concise and directly related to the information provided. - Ensure citations are concise and directly related to the information provided.
@ -1736,6 +1836,12 @@ RAG_WEB_SEARCH_ENGINE = PersistentConfig(
os.getenv("RAG_WEB_SEARCH_ENGINE", ""), os.getenv("RAG_WEB_SEARCH_ENGINE", ""),
) )
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL",
"rag.web.search.bypass_embedding_and_retrieval",
os.getenv("BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL", "False").lower() == "true",
)
# You can provide a list of your own websites to filter after performing a web search. # You can provide a list of your own websites to filter after performing a web search.
# This ensures the highest level of safety and reliability of the information sources. # This ensures the highest level of safety and reliability of the information sources.
RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig( RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig(
@ -1883,10 +1989,34 @@ RAG_WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig(
int(os.getenv("RAG_WEB_SEARCH_CONCURRENT_REQUESTS", "10")), int(os.getenv("RAG_WEB_SEARCH_CONCURRENT_REQUESTS", "10")),
) )
RAG_WEB_LOADER_ENGINE = PersistentConfig(
"RAG_WEB_LOADER_ENGINE",
"rag.web.loader.engine",
os.environ.get("RAG_WEB_LOADER_ENGINE", "safe_web"),
)
RAG_WEB_SEARCH_TRUST_ENV = PersistentConfig( RAG_WEB_SEARCH_TRUST_ENV = PersistentConfig(
"RAG_WEB_SEARCH_TRUST_ENV", "RAG_WEB_SEARCH_TRUST_ENV",
"rag.web.search.trust_env", "rag.web.search.trust_env",
os.getenv("RAG_WEB_SEARCH_TRUST_ENV", False), os.getenv("RAG_WEB_SEARCH_TRUST_ENV", "False").lower() == "true",
)
PLAYWRIGHT_WS_URI = PersistentConfig(
"PLAYWRIGHT_WS_URI",
"rag.web.loader.engine.playwright.ws.uri",
os.environ.get("PLAYWRIGHT_WS_URI", None),
)
FIRECRAWL_API_KEY = PersistentConfig(
"FIRECRAWL_API_KEY",
"firecrawl.api_key",
os.environ.get("FIRECRAWL_API_KEY", ""),
)
FIRECRAWL_API_BASE_URL = PersistentConfig(
"FIRECRAWL_API_BASE_URL",
"firecrawl.api_url",
os.environ.get("FIRECRAWL_API_BASE_URL", "https://api.firecrawl.dev"),
) )
#################################### ####################################
@ -2099,6 +2229,17 @@ IMAGES_OPENAI_API_KEY = PersistentConfig(
os.getenv("IMAGES_OPENAI_API_KEY", OPENAI_API_KEY), os.getenv("IMAGES_OPENAI_API_KEY", OPENAI_API_KEY),
) )
IMAGES_GEMINI_API_BASE_URL = PersistentConfig(
"IMAGES_GEMINI_API_BASE_URL",
"image_generation.gemini.api_base_url",
os.getenv("IMAGES_GEMINI_API_BASE_URL", GEMINI_API_BASE_URL),
)
IMAGES_GEMINI_API_KEY = PersistentConfig(
"IMAGES_GEMINI_API_KEY",
"image_generation.gemini.api_key",
os.getenv("IMAGES_GEMINI_API_KEY", GEMINI_API_KEY),
)
IMAGE_SIZE = PersistentConfig( IMAGE_SIZE = PersistentConfig(
"IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512") "IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512")
) )
@ -2275,7 +2416,7 @@ LDAP_SEARCH_BASE = PersistentConfig(
LDAP_SEARCH_FILTERS = PersistentConfig( LDAP_SEARCH_FILTERS = PersistentConfig(
"LDAP_SEARCH_FILTER", "LDAP_SEARCH_FILTER",
"ldap.server.search_filter", "ldap.server.search_filter",
os.environ.get("LDAP_SEARCH_FILTER", ""), os.environ.get("LDAP_SEARCH_FILTER", os.environ.get("LDAP_SEARCH_FILTERS", "")),
) )
LDAP_USE_TLS = PersistentConfig( LDAP_USE_TLS = PersistentConfig(

View file

@ -419,3 +419,25 @@ OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
if OFFLINE_MODE: if OFFLINE_MODE:
os.environ["HF_HUB_OFFLINE"] = "1" os.environ["HF_HUB_OFFLINE"] = "1"
####################################
# AUDIT LOGGING
####################################
ENABLE_AUDIT_LOGS = os.getenv("ENABLE_AUDIT_LOGS", "false").lower() == "true"
# Where to store log file
AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
# Maximum size of a file before rotating into a new log file
AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB")
# METADATA | REQUEST | REQUEST_RESPONSE
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "REQUEST_RESPONSE").upper()
try:
MAX_BODY_LOG_SIZE = int(os.environ.get("MAX_BODY_LOG_SIZE") or 2048)
except ValueError:
MAX_BODY_LOG_SIZE = 2048
# Comma separated list for urls to exclude from audit
AUDIT_EXCLUDED_PATHS = os.getenv("AUDIT_EXCLUDED_PATHS", "/chats,/chat,/folders").split(
","
)
AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]

View file

@ -2,6 +2,7 @@ import logging
import sys import sys
import inspect import inspect
import json import json
import asyncio
from pydantic import BaseModel from pydantic import BaseModel
from typing import AsyncGenerator, Generator, Iterator from typing import AsyncGenerator, Generator, Iterator
@ -76,10 +77,12 @@ async def get_function_models(request):
if hasattr(function_module, "pipes"): if hasattr(function_module, "pipes"):
sub_pipes = [] sub_pipes = []
# Check if pipes is a function or a list # Handle pipes being a list, sync function, or async function
try: try:
if callable(function_module.pipes): if callable(function_module.pipes):
if asyncio.iscoroutinefunction(function_module.pipes):
sub_pipes = await function_module.pipes()
else:
sub_pipes = function_module.pipes() sub_pipes = function_module.pipes()
else: else:
sub_pipes = function_module.pipes sub_pipes = function_module.pipes

View file

@ -45,6 +45,9 @@ from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import Response, StreamingResponse from starlette.responses import Response, StreamingResponse
from open_webui.utils import logger
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
from open_webui.utils.logger import start_logger
from open_webui.socket.main import ( from open_webui.socket.main import (
app as socket_app, app as socket_app,
periodic_usage_pool_cleanup, periodic_usage_pool_cleanup,
@ -95,12 +98,19 @@ from open_webui.config import (
OLLAMA_API_CONFIGS, OLLAMA_API_CONFIGS,
# OpenAI # OpenAI
ENABLE_OPENAI_API, ENABLE_OPENAI_API,
ONEDRIVE_CLIENT_ID,
OPENAI_API_BASE_URLS, OPENAI_API_BASE_URLS,
OPENAI_API_KEYS, OPENAI_API_KEYS,
OPENAI_API_CONFIGS, OPENAI_API_CONFIGS,
# Direct Connections # Direct Connections
ENABLE_DIRECT_CONNECTIONS, ENABLE_DIRECT_CONNECTIONS,
# Code Interpreter # Code Execution
CODE_EXECUTION_ENGINE,
CODE_EXECUTION_JUPYTER_URL,
CODE_EXECUTION_JUPYTER_AUTH,
CODE_EXECUTION_JUPYTER_AUTH_TOKEN,
CODE_EXECUTION_JUPYTER_AUTH_PASSWORD,
CODE_EXECUTION_JUPYTER_TIMEOUT,
ENABLE_CODE_INTERPRETER, ENABLE_CODE_INTERPRETER,
CODE_INTERPRETER_ENGINE, CODE_INTERPRETER_ENGINE,
CODE_INTERPRETER_PROMPT_TEMPLATE, CODE_INTERPRETER_PROMPT_TEMPLATE,
@ -108,6 +118,7 @@ from open_webui.config import (
CODE_INTERPRETER_JUPYTER_AUTH, CODE_INTERPRETER_JUPYTER_AUTH,
CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
CODE_INTERPRETER_JUPYTER_TIMEOUT,
# Image # Image
AUTOMATIC1111_API_AUTH, AUTOMATIC1111_API_AUTH,
AUTOMATIC1111_BASE_URL, AUTOMATIC1111_BASE_URL,
@ -126,6 +137,8 @@ from open_webui.config import (
IMAGE_STEPS, IMAGE_STEPS,
IMAGES_OPENAI_API_BASE_URL, IMAGES_OPENAI_API_BASE_URL,
IMAGES_OPENAI_API_KEY, IMAGES_OPENAI_API_KEY,
IMAGES_GEMINI_API_BASE_URL,
IMAGES_GEMINI_API_KEY,
# Audio # Audio
AUDIO_STT_ENGINE, AUDIO_STT_ENGINE,
AUDIO_STT_MODEL, AUDIO_STT_MODEL,
@ -140,6 +153,10 @@ from open_webui.config import (
AUDIO_TTS_VOICE, AUDIO_TTS_VOICE,
AUDIO_TTS_AZURE_SPEECH_REGION, AUDIO_TTS_AZURE_SPEECH_REGION,
AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT, AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT,
PLAYWRIGHT_WS_URI,
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
RAG_WEB_LOADER_ENGINE,
WHISPER_MODEL, WHISPER_MODEL,
DEEPGRAM_API_KEY, DEEPGRAM_API_KEY,
WHISPER_MODEL_AUTO_UPDATE, WHISPER_MODEL_AUTO_UPDATE,
@ -147,6 +164,8 @@ from open_webui.config import (
# Retrieval # Retrieval
RAG_TEMPLATE, RAG_TEMPLATE,
DEFAULT_RAG_TEMPLATE, DEFAULT_RAG_TEMPLATE,
RAG_FULL_CONTEXT,
BYPASS_EMBEDDING_AND_RETRIEVAL,
RAG_EMBEDDING_MODEL, RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_MODEL_AUTO_UPDATE, RAG_EMBEDDING_MODEL_AUTO_UPDATE,
RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE, RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
@ -166,6 +185,8 @@ from open_webui.config import (
CHUNK_SIZE, CHUNK_SIZE,
CONTENT_EXTRACTION_ENGINE, CONTENT_EXTRACTION_ENGINE,
TIKA_SERVER_URL, TIKA_SERVER_URL,
DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY,
RAG_TOP_K, RAG_TOP_K,
RAG_TEXT_SPLITTER, RAG_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME, TIKTOKEN_ENCODING_NAME,
@ -174,6 +195,7 @@ from open_webui.config import (
YOUTUBE_LOADER_PROXY_URL, YOUTUBE_LOADER_PROXY_URL,
# Retrieval (Web Search) # Retrieval (Web Search)
RAG_WEB_SEARCH_ENGINE, RAG_WEB_SEARCH_ENGINE,
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
RAG_WEB_SEARCH_RESULT_COUNT, RAG_WEB_SEARCH_RESULT_COUNT,
RAG_WEB_SEARCH_CONCURRENT_REQUESTS, RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
RAG_WEB_SEARCH_TRUST_ENV, RAG_WEB_SEARCH_TRUST_ENV,
@ -200,11 +222,13 @@ from open_webui.config import (
GOOGLE_PSE_ENGINE_ID, GOOGLE_PSE_ENGINE_ID,
GOOGLE_DRIVE_CLIENT_ID, GOOGLE_DRIVE_CLIENT_ID,
GOOGLE_DRIVE_API_KEY, GOOGLE_DRIVE_API_KEY,
ONEDRIVE_CLIENT_ID,
ENABLE_RAG_HYBRID_SEARCH, ENABLE_RAG_HYBRID_SEARCH,
ENABLE_RAG_LOCAL_WEB_FETCH, ENABLE_RAG_LOCAL_WEB_FETCH,
ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
ENABLE_RAG_WEB_SEARCH, ENABLE_RAG_WEB_SEARCH,
ENABLE_GOOGLE_DRIVE_INTEGRATION, ENABLE_GOOGLE_DRIVE_INTEGRATION,
ENABLE_ONEDRIVE_INTEGRATION,
UPLOAD_DIR, UPLOAD_DIR,
# WebUI # WebUI
WEBUI_AUTH, WEBUI_AUTH,
@ -283,8 +307,11 @@ from open_webui.config import (
reset_config, reset_config,
) )
from open_webui.env import ( from open_webui.env import (
AUDIT_EXCLUDED_PATHS,
AUDIT_LOG_LEVEL,
CHANGELOG, CHANGELOG,
GLOBAL_LOG_LEVEL, GLOBAL_LOG_LEVEL,
MAX_BODY_LOG_SIZE,
SAFE_MODE, SAFE_MODE,
SRC_LOG_LEVELS, SRC_LOG_LEVELS,
VERSION, VERSION,
@ -369,6 +396,7 @@ https://github.com/open-webui/open-webui
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
start_logger()
if RESET_CONFIG_ON_START: if RESET_CONFIG_ON_START:
reset_config() reset_config()
@ -509,6 +537,9 @@ app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD
app.state.config.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE app.state.config.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE
app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT
app.state.config.RAG_FULL_CONTEXT = RAG_FULL_CONTEXT
app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL = BYPASS_EMBEDDING_AND_RETRIEVAL
app.state.config.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH app.state.config.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH
app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = (
ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION
@ -516,6 +547,8 @@ app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = (
app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT
app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER
app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME
@ -543,9 +576,13 @@ app.state.config.YOUTUBE_LOADER_PROXY_URL = YOUTUBE_LOADER_PROXY_URL
app.state.config.ENABLE_RAG_WEB_SEARCH = ENABLE_RAG_WEB_SEARCH app.state.config.ENABLE_RAG_WEB_SEARCH = ENABLE_RAG_WEB_SEARCH
app.state.config.RAG_WEB_SEARCH_ENGINE = RAG_WEB_SEARCH_ENGINE app.state.config.RAG_WEB_SEARCH_ENGINE = RAG_WEB_SEARCH_ENGINE
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
)
app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = RAG_WEB_SEARCH_DOMAIN_FILTER_LIST app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = RAG_WEB_SEARCH_DOMAIN_FILTER_LIST
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY
app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID
@ -569,7 +606,11 @@ app.state.config.EXA_API_KEY = EXA_API_KEY
app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = RAG_WEB_SEARCH_RESULT_COUNT 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 app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = RAG_WEB_SEARCH_CONCURRENT_REQUESTS
app.state.config.RAG_WEB_LOADER_ENGINE = RAG_WEB_LOADER_ENGINE
app.state.config.RAG_WEB_SEARCH_TRUST_ENV = RAG_WEB_SEARCH_TRUST_ENV app.state.config.RAG_WEB_SEARCH_TRUST_ENV = RAG_WEB_SEARCH_TRUST_ENV
app.state.config.PLAYWRIGHT_WS_URI = PLAYWRIGHT_WS_URI
app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL
app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY
app.state.EMBEDDING_FUNCTION = None app.state.EMBEDDING_FUNCTION = None
app.state.ef = None app.state.ef = None
@ -613,10 +654,19 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
######################################## ########################################
# #
# CODE INTERPRETER # CODE EXECUTION
# #
######################################## ########################################
app.state.config.CODE_EXECUTION_ENGINE = CODE_EXECUTION_ENGINE
app.state.config.CODE_EXECUTION_JUPYTER_URL = CODE_EXECUTION_JUPYTER_URL
app.state.config.CODE_EXECUTION_JUPYTER_AUTH = CODE_EXECUTION_JUPYTER_AUTH
app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = CODE_EXECUTION_JUPYTER_AUTH_TOKEN
app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = (
CODE_EXECUTION_JUPYTER_AUTH_PASSWORD
)
app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT = CODE_EXECUTION_JUPYTER_TIMEOUT
app.state.config.ENABLE_CODE_INTERPRETER = ENABLE_CODE_INTERPRETER app.state.config.ENABLE_CODE_INTERPRETER = ENABLE_CODE_INTERPRETER
app.state.config.CODE_INTERPRETER_ENGINE = CODE_INTERPRETER_ENGINE app.state.config.CODE_INTERPRETER_ENGINE = CODE_INTERPRETER_ENGINE
app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = CODE_INTERPRETER_PROMPT_TEMPLATE app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = CODE_INTERPRETER_PROMPT_TEMPLATE
@ -629,6 +679,7 @@ app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = (
app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = ( app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = (
CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD
) )
app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = CODE_INTERPRETER_JUPYTER_TIMEOUT
######################################## ########################################
# #
@ -643,6 +694,9 @@ app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL
app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY
app.state.config.IMAGES_GEMINI_API_BASE_URL = IMAGES_GEMINI_API_BASE_URL
app.state.config.IMAGES_GEMINI_API_KEY = IMAGES_GEMINI_API_KEY
app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL
app.state.config.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL app.state.config.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL
@ -844,6 +898,19 @@ app.include_router(
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
try:
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
except ValueError as e:
logger.error(f"Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}")
audit_level = AuditLevel.NONE
if audit_level != AuditLevel.NONE:
app.add_middleware(
AuditLoggingMiddleware,
audit_level=audit_level,
excluded_paths=AUDIT_EXCLUDED_PATHS,
max_body_size=MAX_BODY_LOG_SIZE,
)
################################## ##################################
# #
# Chat Endpoints # Chat Endpoints
@ -876,7 +943,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
return filtered_models return filtered_models
models = await get_all_models(request) models = await get_all_models(request, user=user)
# Filter out filter pipelines # Filter out filter pipelines
models = [ models = [
@ -905,7 +972,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
@app.get("/api/models/base") @app.get("/api/models/base")
async def get_base_models(request: Request, user=Depends(get_admin_user)): async def get_base_models(request: Request, user=Depends(get_admin_user)):
models = await get_all_base_models(request) models = await get_all_base_models(request, user=user)
return {"data": models} return {"data": models}
@ -916,7 +983,7 @@ async def chat_completion(
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
if not request.app.state.MODELS: if not request.app.state.MODELS:
await get_all_models(request) await get_all_models(request, user=user)
model_item = form_data.pop("model_item", {}) model_item = form_data.pop("model_item", {})
tasks = form_data.pop("background_tasks", None) tasks = form_data.pop("background_tasks", None)
@ -952,7 +1019,7 @@ async def chat_completion(
"files": form_data.get("files", None), "files": form_data.get("files", None),
"features": form_data.get("features", None), "features": form_data.get("features", None),
"variables": form_data.get("variables", None), "variables": form_data.get("variables", None),
"model": model_info, "model": model_info.model_dump() if model_info else model,
"direct": model_item.get("direct", False), "direct": model_item.get("direct", False),
**( **(
{"function_calling": "native"} {"function_calling": "native"}
@ -1111,6 +1178,7 @@ async def get_app_config(request: Request):
"enable_admin_export": ENABLE_ADMIN_EXPORT, "enable_admin_export": ENABLE_ADMIN_EXPORT,
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS, "enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, "enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
} }
if user is not None if user is not None
else {} else {}
@ -1120,6 +1188,9 @@ async def get_app_config(request: Request):
{ {
"default_models": app.state.config.DEFAULT_MODELS, "default_models": app.state.config.DEFAULT_MODELS,
"default_prompt_suggestions": app.state.config.DEFAULT_PROMPT_SUGGESTIONS, "default_prompt_suggestions": app.state.config.DEFAULT_PROMPT_SUGGESTIONS,
"code": {
"engine": app.state.config.CODE_EXECUTION_ENGINE,
},
"audio": { "audio": {
"tts": { "tts": {
"engine": app.state.config.TTS_ENGINE, "engine": app.state.config.TTS_ENGINE,
@ -1139,6 +1210,7 @@ async def get_app_config(request: Request):
"client_id": GOOGLE_DRIVE_CLIENT_ID.value, "client_id": GOOGLE_DRIVE_CLIENT_ID.value,
"api_key": GOOGLE_DRIVE_API_KEY.value, "api_key": GOOGLE_DRIVE_API_KEY.value,
}, },
"onedrive": {"client_id": ONEDRIVE_CLIENT_ID.value},
} }
if user is not None if user is not None
else {} else {}

View file

@ -1,3 +1,4 @@
import logging
import json import json
import time import time
import uuid import uuid
@ -5,7 +6,7 @@ from typing import Optional
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
@ -16,6 +17,9 @@ from sqlalchemy.sql import exists
# Chat DB Schema # Chat DB Schema
#################### ####################
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
class Chat(Base): class Chat(Base):
__tablename__ = "chat" __tablename__ = "chat"
@ -670,7 +674,7 @@ class ChatTable:
# Perform pagination at the SQL level # Perform pagination at the SQL level
all_chats = query.offset(skip).limit(limit).all() all_chats = query.offset(skip).limit(limit).all()
print(len(all_chats)) log.info(f"The number of chats: {len(all_chats)}")
# Validate and return chats # Validate and return chats
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
@ -731,7 +735,7 @@ class ChatTable:
query = db.query(Chat).filter_by(user_id=user_id) query = db.query(Chat).filter_by(user_id=user_id)
tag_id = tag_name.replace(" ", "_").lower() tag_id = tag_name.replace(" ", "_").lower()
print(db.bind.dialect.name) log.info(f"DB dialect name: {db.bind.dialect.name}")
if db.bind.dialect.name == "sqlite": if db.bind.dialect.name == "sqlite":
# SQLite JSON1 querying for tags within the meta JSON field # SQLite JSON1 querying for tags within the meta JSON field
query = query.filter( query = query.filter(
@ -752,7 +756,7 @@ class ChatTable:
) )
all_chats = query.all() all_chats = query.all()
print("all_chats", all_chats) log.debug(f"all_chats: {all_chats}")
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def add_chat_tag_by_id_and_user_id_and_tag_name( def add_chat_tag_by_id_and_user_id_and_tag_name(
@ -810,7 +814,7 @@ class ChatTable:
count = query.count() count = query.count()
# Debugging output for inspection # Debugging output for inspection
print(f"Count of chats for tag '{tag_name}':", count) log.info(f"Count of chats for tag '{tag_name}': {count}")
return count return count

View file

@ -118,7 +118,7 @@ class FeedbackTable:
else: else:
return None return None
except Exception as e: except Exception as e:
print(e) log.exception(f"Error creating a new feedback: {e}")
return None return None
def get_feedback_by_id(self, id: str) -> Optional[FeedbackModel]: def get_feedback_by_id(self, id: str) -> Optional[FeedbackModel]:

View file

@ -119,7 +119,7 @@ class FilesTable:
else: else:
return None return None
except Exception as e: except Exception as e:
print(f"Error creating tool: {e}") log.exception(f"Error inserting a new file: {e}")
return None return None
def get_file_by_id(self, id: str) -> Optional[FileModel]: def get_file_by_id(self, id: str) -> Optional[FileModel]:

View file

@ -82,7 +82,7 @@ class FolderTable:
else: else:
return None return None
except Exception as e: except Exception as e:
print(e) log.exception(f"Error inserting a new folder: {e}")
return None return None
def get_folder_by_id_and_user_id( def get_folder_by_id_and_user_id(

View file

@ -105,7 +105,7 @@ class FunctionsTable:
else: else:
return None return None
except Exception as e: except Exception as e:
print(f"Error creating tool: {e}") log.exception(f"Error creating a new function: {e}")
return None return None
def get_function_by_id(self, id: str) -> Optional[FunctionModel]: def get_function_by_id(self, id: str) -> Optional[FunctionModel]:
@ -170,7 +170,7 @@ class FunctionsTable:
function = db.get(Function, id) function = db.get(Function, id)
return function.valves if function.valves else {} return function.valves if function.valves else {}
except Exception as e: except Exception as e:
print(f"An error occurred: {e}") log.exception(f"Error getting function valves by id {id}: {e}")
return None return None
def update_function_valves_by_id( def update_function_valves_by_id(
@ -202,7 +202,9 @@ class FunctionsTable:
return user_settings["functions"]["valves"].get(id, {}) return user_settings["functions"]["valves"].get(id, {})
except Exception as e: except Exception as e:
print(f"An error occurred: {e}") log.exception(
f"Error getting user values by id {id} and user id {user_id}: {e}"
)
return None return None
def update_user_valves_by_id_and_user_id( def update_user_valves_by_id_and_user_id(
@ -225,7 +227,9 @@ class FunctionsTable:
return user_settings["functions"]["valves"][id] return user_settings["functions"]["valves"][id]
except Exception as e: except Exception as e:
print(f"An error occurred: {e}") log.exception(
f"Error updating user valves by id {id} and user_id {user_id}: {e}"
)
return None return None
def update_function_by_id(self, id: str, updated: dict) -> Optional[FunctionModel]: def update_function_by_id(self, id: str, updated: dict) -> Optional[FunctionModel]:

5
backend/open_webui/models/models.py Normal file → Executable file
View file

@ -166,7 +166,7 @@ class ModelsTable:
else: else:
return None return None
except Exception as e: except Exception as e:
print(e) log.exception(f"Failed to insert a new model: {e}")
return None return None
def get_all_models(self) -> list[ModelModel]: def get_all_models(self) -> list[ModelModel]:
@ -246,8 +246,7 @@ class ModelsTable:
db.refresh(model) db.refresh(model)
return ModelModel.model_validate(model) return ModelModel.model_validate(model)
except Exception as e: except Exception as e:
print(e) log.exception(f"Failed to update the model by id {id}: {e}")
return None return None
def delete_model_by_id(self, id: str) -> bool: def delete_model_by_id(self, id: str) -> bool:

View file

@ -61,7 +61,7 @@ class TagTable:
else: else:
return None return None
except Exception as e: except Exception as e:
print(e) log.exception(f"Error inserting a new tag: {e}")
return None return None
def get_tag_by_name_and_user_id( def get_tag_by_name_and_user_id(

View file

@ -131,7 +131,7 @@ class ToolsTable:
else: else:
return None return None
except Exception as e: except Exception as e:
print(f"Error creating tool: {e}") log.exception(f"Error creating a new tool: {e}")
return None return None
def get_tool_by_id(self, id: str) -> Optional[ToolModel]: def get_tool_by_id(self, id: str) -> Optional[ToolModel]:
@ -175,7 +175,7 @@ class ToolsTable:
tool = db.get(Tool, id) tool = db.get(Tool, id)
return tool.valves if tool.valves else {} return tool.valves if tool.valves else {}
except Exception as e: except Exception as e:
print(f"An error occurred: {e}") log.exception(f"Error getting tool valves by id {id}: {e}")
return None return None
def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]: def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]:
@ -204,7 +204,9 @@ class ToolsTable:
return user_settings["tools"]["valves"].get(id, {}) return user_settings["tools"]["valves"].get(id, {})
except Exception as e: except Exception as e:
print(f"An error occurred: {e}") log.exception(
f"Error getting user values by id {id} and user_id {user_id}: {e}"
)
return None return None
def update_user_valves_by_id_and_user_id( def update_user_valves_by_id_and_user_id(
@ -227,7 +229,9 @@ class ToolsTable:
return user_settings["tools"]["valves"][id] return user_settings["tools"]["valves"][id]
except Exception as e: except Exception as e:
print(f"An error occurred: {e}") log.exception(
f"Error updating user valves by id {id} and user_id {user_id}: {e}"
)
return None return None
def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]: def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]:

View file

@ -4,6 +4,7 @@ import ftfy
import sys import sys
from langchain_community.document_loaders import ( from langchain_community.document_loaders import (
AzureAIDocumentIntelligenceLoader,
BSHTMLLoader, BSHTMLLoader,
CSVLoader, CSVLoader,
Docx2txtLoader, Docx2txtLoader,
@ -76,6 +77,7 @@ known_source_ext = [
"jsx", "jsx",
"hs", "hs",
"lhs", "lhs",
"json",
] ]
@ -147,6 +149,27 @@ class Loader:
file_path=file_path, file_path=file_path,
mime_type=file_content_type, mime_type=file_content_type,
) )
elif (
self.engine == "document_intelligence"
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
and self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != ""
and (
file_ext in ["pdf", "xls", "xlsx", "docx", "ppt", "pptx"]
or file_content_type
in [
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
]
)
):
loader = AzureAIDocumentIntelligenceLoader(
file_path=file_path,
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
)
else: else:
if file_ext == "pdf": if file_ext == "pdf":
loader = PyPDFLoader( loader = PyPDFLoader(

View file

@ -1,13 +1,19 @@
import os import os
import logging
import torch import torch
import numpy as np import numpy as np
from colbert.infra import ColBERTConfig from colbert.infra import ColBERTConfig
from colbert.modeling.checkpoint import Checkpoint from colbert.modeling.checkpoint import Checkpoint
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ColBERT: class ColBERT:
def __init__(self, name, **kwargs) -> None: def __init__(self, name, **kwargs) -> None:
print("ColBERT: Loading model", name) log.info("ColBERT: Loading model", name)
self.device = "cuda" if torch.cuda.is_available() else "cpu" self.device = "cuda" if torch.cuda.is_available() else "cpu"
DOCKER = kwargs.get("env") == "docker" DOCKER = kwargs.get("env") == "docker"

View file

@ -5,6 +5,7 @@ from typing import Optional, Union
import asyncio import asyncio
import requests import requests
import hashlib
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
@ -14,8 +15,10 @@ from langchain_core.documents import Document
from open_webui.config import VECTOR_DB from open_webui.config import VECTOR_DB
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
from open_webui.utils.misc import get_last_user_message from open_webui.utils.misc import get_last_user_message, calculate_sha256_string
from open_webui.models.users import UserModel from open_webui.models.users import UserModel
from open_webui.models.files import Files
from open_webui.env import ( from open_webui.env import (
SRC_LOG_LEVELS, SRC_LOG_LEVELS,
@ -80,7 +83,20 @@ def query_doc(
return result return result
except Exception as e: except Exception as e:
print(e) log.exception(f"Error querying doc {collection_name} with limit {k}: {e}")
raise e
def get_doc(collection_name: str, user: UserModel = None):
try:
result = VECTOR_DB_CLIENT.get(collection_name=collection_name)
if result:
log.info(f"query_doc:result {result.ids} {result.metadatas}")
return result
except Exception as e:
log.exception(f"Error getting doc {collection_name}: {e}")
raise e raise e
@ -137,47 +153,80 @@ def query_doc_with_hybrid_search(
raise e raise e
def merge_and_sort_query_results( def merge_get_results(get_results: list[dict]) -> dict:
query_results: list[dict], k: int, reverse: bool = False
) -> list[dict]:
# Initialize lists to store combined data # Initialize lists to store combined data
combined_distances = []
combined_documents = [] combined_documents = []
combined_metadatas = [] combined_metadatas = []
combined_ids = []
for data in query_results: for data in get_results:
combined_distances.extend(data["distances"][0])
combined_documents.extend(data["documents"][0]) combined_documents.extend(data["documents"][0])
combined_metadatas.extend(data["metadatas"][0]) combined_metadatas.extend(data["metadatas"][0])
combined_ids.extend(data["ids"][0])
# Create a list of tuples (distance, document, metadata) # Create the output dictionary
combined = list(zip(combined_distances, combined_documents, combined_metadatas)) result = {
"documents": [combined_documents],
"metadatas": [combined_metadatas],
"ids": [combined_ids],
}
return result
def merge_and_sort_query_results(
query_results: list[dict], k: int, reverse: bool = False
) -> dict:
# Initialize lists to store combined data
combined = []
seen_hashes = set() # To store unique document hashes
for data in query_results:
distances = data["distances"][0]
documents = data["documents"][0]
metadatas = data["metadatas"][0]
for distance, document, metadata in zip(distances, documents, metadatas):
if isinstance(document, str):
doc_hash = hashlib.md5(
document.encode()
).hexdigest() # Compute a hash for uniqueness
if doc_hash not in seen_hashes:
seen_hashes.add(doc_hash)
combined.append((distance, document, metadata))
# Sort the list based on distances # Sort the list based on distances
combined.sort(key=lambda x: x[0], reverse=reverse) combined.sort(key=lambda x: x[0], reverse=reverse)
# We don't have anything :-( # Slice to keep only the top k elements
if not combined: sorted_distances, sorted_documents, sorted_metadatas = (
sorted_distances = [] zip(*combined[:k]) if combined else ([], [], [])
sorted_documents = [] )
sorted_metadatas = []
else:
# Unzip the sorted list
sorted_distances, sorted_documents, sorted_metadatas = zip(*combined)
# Slicing the lists to include only k elements # Create and return the output dictionary
sorted_distances = list(sorted_distances)[:k] return {
sorted_documents = list(sorted_documents)[:k] "distances": [list(sorted_distances)],
sorted_metadatas = list(sorted_metadatas)[:k] "documents": [list(sorted_documents)],
"metadatas": [list(sorted_metadatas)],
# Create the output dictionary
result = {
"distances": [sorted_distances],
"documents": [sorted_documents],
"metadatas": [sorted_metadatas],
} }
return result
def get_all_items_from_collections(collection_names: list[str]) -> dict:
results = []
for collection_name in collection_names:
if collection_name:
try:
result = get_doc(collection_name=collection_name)
if result is not None:
results.append(result.model_dump())
except Exception as e:
log.exception(f"Error when querying the collection: {e}")
else:
pass
return merge_get_results(results)
def query_collection( def query_collection(
@ -290,6 +339,7 @@ def get_embedding_function(
def get_sources_from_files( def get_sources_from_files(
request,
files, files,
queries, queries,
embedding_function, embedding_function,
@ -297,21 +347,74 @@ def get_sources_from_files(
reranking_function, reranking_function,
r, r,
hybrid_search, hybrid_search,
full_context=False,
): ):
log.debug(f"files: {files} {queries} {embedding_function} {reranking_function}") log.debug(
f"files: {files} {queries} {embedding_function} {reranking_function} {full_context}"
)
extracted_collections = [] extracted_collections = []
relevant_contexts = [] relevant_contexts = []
for file in files: for file in files:
if file.get("context") == "full":
context = None
if file.get("docs"):
# BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
context = {
"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 = { context = {
"documents": [[file.get("file").get("data", {}).get("content")]], "documents": [[file.get("file").get("data", {}).get("content")]],
"metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]], "metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]],
} }
else: elif (
context = None file.get("type") != "web_search"
and request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
):
# BYPASS_EMBEDDING_AND_RETRIEVAL
if file.get("type") == "collection":
file_ids = file.get("data", {}).get("file_ids", [])
documents = []
metadatas = []
for file_id in file_ids:
file_object = Files.get_file_by_id(file_id)
if file_object:
documents.append(file_object.data.get("content", ""))
metadatas.append(
{
"file_id": file_id,
"name": file_object.filename,
"source": file_object.filename,
}
)
context = {
"documents": [documents],
"metadatas": [metadatas],
}
elif file.get("id"):
file_object = Files.get_file_by_id(file.get("id"))
if file_object:
context = {
"documents": [[file_object.data.get("content", "")]],
"metadatas": [
[
{
"file_id": file.get("id"),
"name": file_object.filename,
"source": file_object.filename,
}
]
],
}
else:
collection_names = [] collection_names = []
if file.get("type") == "collection": if file.get("type") == "collection":
if file.get("legacy"): if file.get("legacy"):
@ -331,6 +434,13 @@ def get_sources_from_files(
log.debug(f"skipping {file} as it has already been extracted") log.debug(f"skipping {file} as it has already been extracted")
continue continue
if full_context:
try:
context = get_all_items_from_collections(collection_names)
except Exception as e:
log.exception(e)
else:
try: try:
context = None context = None
if file.get("type") == "text": if file.get("type") == "text":
@ -367,6 +477,7 @@ def get_sources_from_files(
if context: if context:
if "data" in file: if "data" in file:
del file["data"] del file["data"]
relevant_contexts.append({**context, "file": file}) relevant_contexts.append({**context, "file": file})
sources = [] sources = []
@ -463,7 +574,7 @@ def generate_openai_batch_embeddings(
else: else:
raise "Something went wrong :/" raise "Something went wrong :/"
except Exception as e: except Exception as e:
print(e) log.exception(f"Error generating openai batch embeddings: {e}")
return None return None
@ -497,7 +608,7 @@ def generate_ollama_batch_embeddings(
else: else:
raise "Something went wrong :/" raise "Something went wrong :/"
except Exception as e: except Exception as e:
print(e) log.exception(f"Error generating ollama batch embeddings: {e}")
return None return None

8
backend/open_webui/retrieval/vector/dbs/chroma.py Normal file → Executable file
View file

@ -1,4 +1,5 @@
import chromadb import chromadb
import logging
from chromadb import Settings from chromadb import Settings
from chromadb.utils.batch_utils import create_batches from chromadb.utils.batch_utils import create_batches
@ -16,6 +17,10 @@ from open_webui.config import (
CHROMA_CLIENT_AUTH_PROVIDER, CHROMA_CLIENT_AUTH_PROVIDER,
CHROMA_CLIENT_AUTH_CREDENTIALS, CHROMA_CLIENT_AUTH_CREDENTIALS,
) )
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ChromaClient: class ChromaClient:
@ -102,8 +107,7 @@ class ChromaClient:
} }
) )
return None return None
except Exception as e: except:
print(e)
return None return None
def get(self, collection_name: str) -> Optional[GetResult]: def get(self, collection_name: str) -> Optional[GetResult]:

View file

@ -1,7 +1,7 @@
from pymilvus import MilvusClient as Client from pymilvus import MilvusClient as Client
from pymilvus import FieldSchema, DataType from pymilvus import FieldSchema, DataType
import json import json
import logging
from typing import Optional from typing import Optional
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
@ -10,6 +10,10 @@ from open_webui.config import (
MILVUS_DB, MILVUS_DB,
MILVUS_TOKEN, MILVUS_TOKEN,
) )
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class MilvusClient: class MilvusClient:
@ -168,7 +172,7 @@ class MilvusClient:
try: try:
# Loop until there are no more items to fetch or the desired limit is reached # Loop until there are no more items to fetch or the desired limit is reached
while remaining > 0: while remaining > 0:
print("remaining", remaining) log.info(f"remaining: {remaining}")
current_fetch = min( current_fetch = min(
max_limit, remaining max_limit, remaining
) # Determine how many items to fetch in this iteration ) # Determine how many items to fetch in this iteration
@ -195,10 +199,12 @@ class MilvusClient:
if results_count < current_fetch: if results_count < current_fetch:
break break
print(all_results) log.debug(all_results)
return self._result_to_get_result([all_results]) return self._result_to_get_result([all_results])
except Exception as e: except Exception as e:
print(e) log.exception(
f"Error querying collection {collection_name} with limit {limit}: {e}"
)
return None return None
def get(self, collection_name: str) -> Optional[GetResult]: def get(self, collection_name: str) -> Optional[GetResult]:

View file

@ -1,4 +1,5 @@
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
import logging
from sqlalchemy import ( from sqlalchemy import (
cast, cast,
column, column,
@ -24,9 +25,14 @@ from sqlalchemy.exc import NoSuchTableError
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
from open_webui.env import SRC_LOG_LEVELS
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
Base = declarative_base() Base = declarative_base()
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class DocumentChunk(Base): class DocumentChunk(Base):
__tablename__ = "document_chunk" __tablename__ = "document_chunk"
@ -82,10 +88,10 @@ class PgvectorClient:
) )
) )
self.session.commit() self.session.commit()
print("Initialization complete.") log.info("Initialization complete.")
except Exception as e: except Exception as e:
self.session.rollback() self.session.rollback()
print(f"Error during initialization: {e}") log.exception(f"Error during initialization: {e}")
raise raise
def check_vector_length(self) -> None: def check_vector_length(self) -> None:
@ -150,12 +156,12 @@ class PgvectorClient:
new_items.append(new_chunk) new_items.append(new_chunk)
self.session.bulk_save_objects(new_items) self.session.bulk_save_objects(new_items)
self.session.commit() self.session.commit()
print( log.info(
f"Inserted {len(new_items)} items into collection '{collection_name}'." f"Inserted {len(new_items)} items into collection '{collection_name}'."
) )
except Exception as e: except Exception as e:
self.session.rollback() self.session.rollback()
print(f"Error during insert: {e}") log.exception(f"Error during insert: {e}")
raise raise
def upsert(self, collection_name: str, items: List[VectorItem]) -> None: def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
@ -184,10 +190,12 @@ class PgvectorClient:
) )
self.session.add(new_chunk) self.session.add(new_chunk)
self.session.commit() self.session.commit()
print(f"Upserted {len(items)} items into collection '{collection_name}'.") log.info(
f"Upserted {len(items)} items into collection '{collection_name}'."
)
except Exception as e: except Exception as e:
self.session.rollback() self.session.rollback()
print(f"Error during upsert: {e}") log.exception(f"Error during upsert: {e}")
raise raise
def search( def search(
@ -278,7 +286,7 @@ class PgvectorClient:
ids=ids, distances=distances, documents=documents, metadatas=metadatas ids=ids, distances=distances, documents=documents, metadatas=metadatas
) )
except Exception as e: except Exception as e:
print(f"Error during search: {e}") log.exception(f"Error during search: {e}")
return None return None
def query( def query(
@ -310,7 +318,7 @@ class PgvectorClient:
metadatas=metadatas, metadatas=metadatas,
) )
except Exception as e: except Exception as e:
print(f"Error during query: {e}") log.exception(f"Error during query: {e}")
return None return None
def get( def get(
@ -334,7 +342,7 @@ class PgvectorClient:
return GetResult(ids=ids, documents=documents, metadatas=metadatas) return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e: except Exception as e:
print(f"Error during get: {e}") log.exception(f"Error during get: {e}")
return None return None
def delete( def delete(
@ -356,22 +364,22 @@ class PgvectorClient:
) )
deleted = query.delete(synchronize_session=False) deleted = query.delete(synchronize_session=False)
self.session.commit() self.session.commit()
print(f"Deleted {deleted} items from collection '{collection_name}'.") log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
except Exception as e: except Exception as e:
self.session.rollback() self.session.rollback()
print(f"Error during delete: {e}") log.exception(f"Error during delete: {e}")
raise raise
def reset(self) -> None: def reset(self) -> None:
try: try:
deleted = self.session.query(DocumentChunk).delete() deleted = self.session.query(DocumentChunk).delete()
self.session.commit() self.session.commit()
print( log.info(
f"Reset complete. Deleted {deleted} items from 'document_chunk' table." f"Reset complete. Deleted {deleted} items from 'document_chunk' table."
) )
except Exception as e: except Exception as e:
self.session.rollback() self.session.rollback()
print(f"Error during reset: {e}") log.exception(f"Error during reset: {e}")
raise raise
def close(self) -> None: def close(self) -> None:
@ -387,9 +395,9 @@ class PgvectorClient:
) )
return exists return exists
except Exception as e: except Exception as e:
print(f"Error checking collection existence: {e}") log.exception(f"Error checking collection existence: {e}")
return False return False
def delete_collection(self, collection_name: str) -> None: def delete_collection(self, collection_name: str) -> None:
self.delete(collection_name) self.delete(collection_name)
print(f"Collection '{collection_name}' deleted.") log.info(f"Collection '{collection_name}' deleted.")

View file

@ -1,4 +1,5 @@
from typing import Optional from typing import Optional
import logging
from qdrant_client import QdrantClient as Qclient from qdrant_client import QdrantClient as Qclient
from qdrant_client.http.models import PointStruct from qdrant_client.http.models import PointStruct
@ -6,9 +7,13 @@ from qdrant_client.models import models
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import QDRANT_URI, QDRANT_API_KEY from open_webui.config import QDRANT_URI, QDRANT_API_KEY
from open_webui.env import SRC_LOG_LEVELS
NO_LIMIT = 999999999 NO_LIMIT = 999999999
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class QdrantClient: class QdrantClient:
def __init__(self): def __init__(self):
@ -49,7 +54,7 @@ class QdrantClient:
), ),
) )
print(f"collection {collection_name_with_prefix} successfully created!") log.info(f"collection {collection_name_with_prefix} successfully created!")
def _create_collection_if_not_exists(self, collection_name, dimension): def _create_collection_if_not_exists(self, collection_name, dimension):
if not self.has_collection(collection_name=collection_name): if not self.has_collection(collection_name=collection_name):
@ -120,7 +125,7 @@ class QdrantClient:
) )
return self._result_to_get_result(points.points) return self._result_to_get_result(points.points)
except Exception as e: except Exception as e:
print(e) log.exception(f"Error querying a collection '{collection_name}': {e}")
return None return None
def get(self, collection_name: str) -> Optional[GetResult]: def get(self, collection_name: str) -> Optional[GetResult]:

View file

@ -27,8 +27,7 @@ def search_tavily(
""" """
url = "https://api.tavily.com/search" url = "https://api.tavily.com/search"
data = {"query": query, "api_key": api_key} data = {"query": query, "api_key": api_key}
include_domain = filter_list response = requests.post(url, json=data)
response = requests.post(url, include_domain, json=data)
response.raise_for_status() response.raise_for_status()
json_response = response.json() json_response = response.json()

View file

@ -1,22 +1,38 @@
import socket
import aiohttp
import asyncio import asyncio
import urllib.parse
import validators
from typing import Any, AsyncIterator, Dict, Iterator, List, Sequence, Union
from langchain_community.document_loaders import (
WebBaseLoader,
)
from langchain_core.documents import Document
from open_webui.constants import ERROR_MESSAGES
from open_webui.config import ENABLE_RAG_LOCAL_WEB_FETCH
from open_webui.env import SRC_LOG_LEVELS
import logging import logging
import socket
import ssl
import urllib.parse
import urllib.request
from collections import defaultdict
from datetime import datetime, time, timedelta
from typing import (
Any,
AsyncIterator,
Dict,
Iterator,
List,
Optional,
Sequence,
Union,
Literal,
)
import aiohttp
import certifi
import validators
from langchain_community.document_loaders import PlaywrightURLLoader, WebBaseLoader
from langchain_community.document_loaders.firecrawl import FireCrawlLoader
from langchain_community.document_loaders.base import BaseLoader
from langchain_core.documents import Document
from open_webui.constants import ERROR_MESSAGES
from open_webui.config import (
ENABLE_RAG_LOCAL_WEB_FETCH,
PLAYWRIGHT_WS_URI,
RAG_WEB_LOADER_ENGINE,
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
)
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"]) log.setLevel(SRC_LOG_LEVELS["RAG"])
@ -68,6 +84,314 @@ def resolve_hostname(hostname):
return ipv4_addresses, ipv6_addresses return ipv4_addresses, ipv6_addresses
def extract_metadata(soup, url):
metadata = {"source": url}
if title := soup.find("title"):
metadata["title"] = title.get_text()
if description := soup.find("meta", attrs={"name": "description"}):
metadata["description"] = description.get("content", "No description found.")
if html := soup.find("html"):
metadata["language"] = html.get("lang", "No language found.")
return metadata
def verify_ssl_cert(url: str) -> bool:
"""Verify SSL certificate for the given URL."""
if not url.startswith("https://"):
return True
try:
hostname = url.split("://")[-1].split("/")[0]
context = ssl.create_default_context(cafile=certifi.where())
with context.wrap_socket(ssl.socket(), server_hostname=hostname) as s:
s.connect((hostname, 443))
return True
except ssl.SSLError:
return False
except Exception as e:
log.warning(f"SSL verification failed for {url}: {str(e)}")
return False
class SafeFireCrawlLoader(BaseLoader):
def __init__(
self,
web_paths,
verify_ssl: bool = True,
trust_env: bool = False,
requests_per_second: Optional[float] = None,
continue_on_failure: bool = True,
api_key: Optional[str] = None,
api_url: Optional[str] = None,
mode: Literal["crawl", "scrape", "map"] = "crawl",
proxy: Optional[Dict[str, str]] = None,
params: Optional[Dict] = None,
):
"""Concurrent document loader for FireCrawl operations.
Executes multiple FireCrawlLoader instances concurrently using thread pooling
to improve bulk processing efficiency.
Args:
web_paths: List of URLs/paths to process.
verify_ssl: If True, verify SSL certificates.
trust_env: If True, use proxy settings from environment variables.
requests_per_second: Number of requests per second to limit to.
continue_on_failure (bool): If True, continue loading other URLs on failure.
api_key: API key for FireCrawl service. Defaults to None
(uses FIRE_CRAWL_API_KEY environment variable if not provided).
api_url: Base URL for FireCrawl API. Defaults to official API endpoint.
mode: Operation mode selection:
- 'crawl': Website crawling mode (default)
- 'scrape': Direct page scraping
- 'map': Site map generation
proxy: Proxy override settings for the FireCrawl API.
params: The parameters to pass to the Firecrawl API.
Examples include crawlerOptions.
For more details, visit: https://github.com/mendableai/firecrawl-py
"""
proxy_server = proxy.get("server") if proxy else None
if trust_env and not proxy_server:
env_proxies = urllib.request.getproxies()
env_proxy_server = env_proxies.get("https") or env_proxies.get("http")
if env_proxy_server:
if proxy:
proxy["server"] = env_proxy_server
else:
proxy = {"server": env_proxy_server}
self.web_paths = web_paths
self.verify_ssl = verify_ssl
self.requests_per_second = requests_per_second
self.last_request_time = None
self.trust_env = trust_env
self.continue_on_failure = continue_on_failure
self.api_key = api_key
self.api_url = api_url
self.mode = mode
self.params = params
def lazy_load(self) -> Iterator[Document]:
"""Load documents concurrently using FireCrawl."""
for url in self.web_paths:
try:
self._safe_process_url_sync(url)
loader = FireCrawlLoader(
url=url,
api_key=self.api_key,
api_url=self.api_url,
mode=self.mode,
params=self.params,
)
yield from loader.lazy_load()
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
continue
raise e
async def alazy_load(self):
"""Async version of lazy_load."""
for url in self.web_paths:
try:
await self._safe_process_url(url)
loader = FireCrawlLoader(
url=url,
api_key=self.api_key,
api_url=self.api_url,
mode=self.mode,
params=self.params,
)
async for document in loader.alazy_load():
yield document
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
continue
raise e
def _verify_ssl_cert(self, url: str) -> bool:
return verify_ssl_cert(url)
async def _wait_for_rate_limit(self):
"""Wait to respect the rate limit if specified."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
await asyncio.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
def _sync_wait_for_rate_limit(self):
"""Synchronous version of rate limit wait."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
time.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
async def _safe_process_url(self, url: str) -> bool:
"""Perform safety checks before processing a URL."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
await self._wait_for_rate_limit()
return True
def _safe_process_url_sync(self, url: str) -> bool:
"""Synchronous version of safety checks."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit()
return True
class SafePlaywrightURLLoader(PlaywrightURLLoader):
"""Load HTML pages safely with Playwright, supporting SSL verification, rate limiting, and remote browser connection.
Attributes:
web_paths (List[str]): List of URLs to load.
verify_ssl (bool): If True, verify SSL certificates.
trust_env (bool): If True, use proxy settings from environment variables.
requests_per_second (Optional[float]): Number of requests per second to limit to.
continue_on_failure (bool): If True, continue loading other URLs on failure.
headless (bool): If True, the browser will run in headless mode.
proxy (dict): Proxy override settings for the Playwright session.
playwright_ws_url (Optional[str]): WebSocket endpoint URI for remote browser connection.
"""
def __init__(
self,
web_paths: List[str],
verify_ssl: bool = True,
trust_env: bool = False,
requests_per_second: Optional[float] = None,
continue_on_failure: bool = True,
headless: bool = True,
remove_selectors: Optional[List[str]] = None,
proxy: Optional[Dict[str, str]] = None,
playwright_ws_url: Optional[str] = None,
):
"""Initialize with additional safety parameters and remote browser support."""
proxy_server = proxy.get("server") if proxy else None
if trust_env and not proxy_server:
env_proxies = urllib.request.getproxies()
env_proxy_server = env_proxies.get("https") or env_proxies.get("http")
if env_proxy_server:
if proxy:
proxy["server"] = env_proxy_server
else:
proxy = {"server": env_proxy_server}
# We'll set headless to False if using playwright_ws_url since it's handled by the remote browser
super().__init__(
urls=web_paths,
continue_on_failure=continue_on_failure,
headless=headless if playwright_ws_url is None else False,
remove_selectors=remove_selectors,
proxy=proxy,
)
self.verify_ssl = verify_ssl
self.requests_per_second = requests_per_second
self.last_request_time = None
self.playwright_ws_url = playwright_ws_url
self.trust_env = trust_env
def lazy_load(self) -> Iterator[Document]:
"""Safely load URLs synchronously with support for remote browser."""
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
# Use remote browser if ws_endpoint is provided, otherwise use local browser
if self.playwright_ws_url:
browser = p.chromium.connect(self.playwright_ws_url)
else:
browser = p.chromium.launch(headless=self.headless, proxy=self.proxy)
for url in self.urls:
try:
self._safe_process_url_sync(url)
page = browser.new_page()
response = page.goto(url)
if response is None:
raise ValueError(f"page.goto() returned None for url {url}")
text = self.evaluator.evaluate(page, browser, response)
metadata = {"source": url}
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
continue
raise e
browser.close()
async def alazy_load(self) -> AsyncIterator[Document]:
"""Safely load URLs asynchronously with support for remote browser."""
from playwright.async_api import async_playwright
async with async_playwright() as p:
# Use remote browser if ws_endpoint is provided, otherwise use local browser
if self.playwright_ws_url:
browser = await p.chromium.connect(self.playwright_ws_url)
else:
browser = await p.chromium.launch(
headless=self.headless, proxy=self.proxy
)
for url in self.urls:
try:
await self._safe_process_url(url)
page = await browser.new_page()
response = await page.goto(url)
if response is None:
raise ValueError(f"page.goto() returned None for url {url}")
text = await self.evaluator.evaluate_async(page, browser, response)
metadata = {"source": url}
yield Document(page_content=text, metadata=metadata)
except Exception as e:
if self.continue_on_failure:
log.exception(e, "Error loading %s", url)
continue
raise e
await browser.close()
def _verify_ssl_cert(self, url: str) -> bool:
return verify_ssl_cert(url)
async def _wait_for_rate_limit(self):
"""Wait to respect the rate limit if specified."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
await asyncio.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
def _sync_wait_for_rate_limit(self):
"""Synchronous version of rate limit wait."""
if self.requests_per_second and self.last_request_time:
min_interval = timedelta(seconds=1.0 / self.requests_per_second)
time_since_last = datetime.now() - self.last_request_time
if time_since_last < min_interval:
time.sleep((min_interval - time_since_last).total_seconds())
self.last_request_time = datetime.now()
async def _safe_process_url(self, url: str) -> bool:
"""Perform safety checks before processing a URL."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
await self._wait_for_rate_limit()
return True
def _safe_process_url_sync(self, url: str) -> bool:
"""Synchronous version of safety checks."""
if self.verify_ssl and not self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit()
return True
class SafeWebBaseLoader(WebBaseLoader): class SafeWebBaseLoader(WebBaseLoader):
"""WebBaseLoader with enhanced error handling for URLs.""" """WebBaseLoader with enhanced error handling for URLs."""
@ -143,20 +467,12 @@ class SafeWebBaseLoader(WebBaseLoader):
text = soup.get_text(**self.bs_get_text_kwargs) text = soup.get_text(**self.bs_get_text_kwargs)
# Build metadata # Build metadata
metadata = {"source": path} metadata = extract_metadata(soup, path)
if title := soup.find("title"):
metadata["title"] = title.get_text()
if description := soup.find("meta", attrs={"name": "description"}):
metadata["description"] = description.get(
"content", "No description found."
)
if html := soup.find("html"):
metadata["language"] = html.get("lang", "No language found.")
yield Document(page_content=text, metadata=metadata) yield Document(page_content=text, metadata=metadata)
except Exception as e: except Exception as e:
# Log the error and continue with the next URL # Log the error and continue with the next URL
log.error(f"Error loading {path}: {e}") log.exception(e, "Error loading %s", path)
async def alazy_load(self) -> AsyncIterator[Document]: async def alazy_load(self) -> AsyncIterator[Document]:
"""Async lazy load text from the url(s) in web_path.""" """Async lazy load text from the url(s) in web_path."""
@ -179,6 +495,12 @@ class SafeWebBaseLoader(WebBaseLoader):
return [document async for document in self.alazy_load()] return [document async for document in self.alazy_load()]
RAG_WEB_LOADER_ENGINES = defaultdict(lambda: SafeWebBaseLoader)
RAG_WEB_LOADER_ENGINES["playwright"] = SafePlaywrightURLLoader
RAG_WEB_LOADER_ENGINES["safe_web"] = SafeWebBaseLoader
RAG_WEB_LOADER_ENGINES["firecrawl"] = SafeFireCrawlLoader
def get_web_loader( def get_web_loader(
urls: Union[str, Sequence[str]], urls: Union[str, Sequence[str]],
verify_ssl: bool = True, verify_ssl: bool = True,
@ -188,10 +510,29 @@ def get_web_loader(
# Check if the URLs are valid # Check if the URLs are valid
safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls) safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls)
return SafeWebBaseLoader( web_loader_args = {
web_path=safe_urls, "web_paths": safe_urls,
verify_ssl=verify_ssl, "verify_ssl": verify_ssl,
requests_per_second=requests_per_second, "requests_per_second": requests_per_second,
continue_on_failure=True, "continue_on_failure": True,
trust_env=trust_env, "trust_env": trust_env,
}
if PLAYWRIGHT_WS_URI.value:
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URI.value
if RAG_WEB_LOADER_ENGINE.value == "firecrawl":
web_loader_args["api_key"] = FIRECRAWL_API_KEY.value
web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value
# Create the appropriate WebLoader based on the configuration
WebLoaderClass = RAG_WEB_LOADER_ENGINES[RAG_WEB_LOADER_ENGINE.value]
web_loader = WebLoaderClass(**web_loader_args)
log.debug(
"Using RAG_WEB_LOADER_ENGINE %s for %s URLs",
web_loader.__class__.__name__,
len(safe_urls),
) )
return web_loader

View file

@ -37,6 +37,7 @@ from open_webui.config import (
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ( from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
ENV, ENV,
SRC_LOG_LEVELS, SRC_LOG_LEVELS,
DEVICE_TYPE, DEVICE_TYPE,
@ -70,7 +71,7 @@ from pydub.utils import mediainfo
def is_mp4_audio(file_path): def is_mp4_audio(file_path):
"""Check if the given file is an MP4 audio file.""" """Check if the given file is an MP4 audio file."""
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
print(f"File not found: {file_path}") log.error(f"File not found: {file_path}")
return False return False
info = mediainfo(file_path) info = mediainfo(file_path)
@ -87,7 +88,7 @@ def convert_mp4_to_wav(file_path, output_path):
"""Convert MP4 audio file to WAV format.""" """Convert MP4 audio file to WAV format."""
audio = AudioSegment.from_file(file_path, format="mp4") audio = AudioSegment.from_file(file_path, format="mp4")
audio.export(output_path, format="wav") audio.export(output_path, format="wav")
print(f"Converted {file_path} to {output_path}") log.info(f"Converted {file_path} to {output_path}")
def set_faster_whisper_model(model: str, auto_update: bool = False): def set_faster_whisper_model(model: str, auto_update: bool = False):
@ -265,8 +266,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
payload["model"] = request.app.state.config.TTS_MODEL payload["model"] = request.app.state.config.TTS_MODEL
try: try:
# print(payload) timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession(
timeout=timeout, trust_env=True
) as session:
async with session.post( async with session.post(
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech", url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
json=payload, json=payload,
@ -323,7 +326,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
) )
try: try:
async with aiohttp.ClientSession() as session: timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
async with aiohttp.ClientSession(
timeout=timeout, trust_env=True
) as session:
async with session.post( async with session.post(
f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}", f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}",
json={ json={
@ -380,7 +386,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
data = f"""<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="{locale}"> data = f"""<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="{locale}">
<voice name="{language}">{payload["input"]}</voice> <voice name="{language}">{payload["input"]}</voice>
</speak>""" </speak>"""
async with aiohttp.ClientSession() as session: timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
async with aiohttp.ClientSession(
timeout=timeout, trust_env=True
) as session:
async with session.post( async with session.post(
f"https://{region}.tts.speech.microsoft.com/cognitiveservices/v1", f"https://{region}.tts.speech.microsoft.com/cognitiveservices/v1",
headers={ headers={
@ -458,7 +467,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
def transcribe(request: Request, file_path): def transcribe(request: Request, file_path):
print("transcribe", file_path) log.info(f"transcribe: {file_path}")
filename = os.path.basename(file_path) filename = os.path.basename(file_path)
file_dir = os.path.dirname(file_path) file_dir = os.path.dirname(file_path)
id = filename.split(".")[0] id = filename.split(".")[0]
@ -670,6 +679,21 @@ def transcription(
def get_available_models(request: Request) -> list[dict]: def get_available_models(request: Request) -> list[dict]:
available_models = [] available_models = []
if request.app.state.config.TTS_ENGINE == "openai": if request.app.state.config.TTS_ENGINE == "openai":
# Use custom endpoint if not using the official OpenAI API URL
if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith(
"https://api.openai.com"
):
try:
response = requests.get(
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models"
)
response.raise_for_status()
data = response.json()
available_models = data.get("models", [])
except Exception as e:
log.error(f"Error fetching models from custom endpoint: {str(e)}")
available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}]
else:
available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}] available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}]
elif request.app.state.config.TTS_ENGINE == "elevenlabs": elif request.app.state.config.TTS_ENGINE == "elevenlabs":
try: try:
@ -701,6 +725,29 @@ def get_available_voices(request) -> dict:
"""Returns {voice_id: voice_name} dict""" """Returns {voice_id: voice_name} dict"""
available_voices = {} available_voices = {}
if request.app.state.config.TTS_ENGINE == "openai": if request.app.state.config.TTS_ENGINE == "openai":
# Use custom endpoint if not using the official OpenAI API URL
if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith(
"https://api.openai.com"
):
try:
response = requests.get(
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices"
)
response.raise_for_status()
data = response.json()
voices_list = data.get("voices", [])
available_voices = {voice["id"]: voice["name"] for voice in voices_list}
except Exception as e:
log.error(f"Error fetching voices from custom endpoint: {str(e)}")
available_voices = {
"alloy": "alloy",
"echo": "echo",
"fable": "fable",
"onyx": "onyx",
"nova": "nova",
"shimmer": "shimmer",
}
else:
available_voices = { available_voices = {
"alloy": "alloy", "alloy": "alloy",
"echo": "echo", "echo": "echo",

View file

@ -31,10 +31,7 @@ from open_webui.env import (
) )
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response from fastapi.responses import RedirectResponse, Response
from open_webui.config import ( from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP
OPENID_PROVIDER_URL,
ENABLE_OAUTH_SIGNUP,
)
from pydantic import BaseModel from pydantic import BaseModel
from open_webui.utils.misc import parse_duration, validate_email_format from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.auth import ( from open_webui.utils.auth import (
@ -51,8 +48,10 @@ from open_webui.utils.access_control import get_permissions
from typing import Optional, List from typing import Optional, List
from ssl import CERT_REQUIRED, PROTOCOL_TLS from ssl import CERT_REQUIRED, PROTOCOL_TLS
from ldap3 import Server, Connection, NONE, Tls
from ldap3.utils.conv import escape_filter_chars if ENABLE_LDAP.value:
from ldap3 import Server, Connection, NONE, Tls
from ldap3.utils.conv import escape_filter_chars
router = APIRouter() router = APIRouter()
@ -252,14 +251,6 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
if not user: if not user:
try: try:
user_count = Users.get_num_users() user_count = Users.get_num_users()
if (
request.app.state.USER_COUNT
and user_count >= request.app.state.USER_COUNT
):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
role = ( role = (
"admin" "admin"
@ -423,7 +414,6 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
@router.post("/signup", response_model=SessionUserResponse) @router.post("/signup", response_model=SessionUserResponse)
async def signup(request: Request, response: Response, form_data: SignupForm): async def signup(request: Request, response: Response, form_data: SignupForm):
user_count = Users.get_num_users()
if WEBUI_AUTH: if WEBUI_AUTH:
if ( if (
@ -434,16 +424,12 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
) )
else: else:
if user_count != 0: if Users.get_num_users() != 0:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
if request.app.state.USER_COUNT and user_count >= request.app.state.USER_COUNT:
raise HTTPException( raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
) )
user_count = Users.get_num_users()
if not validate_email_format(form_data.email.lower()): if not validate_email_format(form_data.email.lower()):
raise HTTPException( raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
@ -546,7 +532,8 @@ async def signout(request: Request, response: Response):
if logout_url: if logout_url:
response.delete_cookie("oauth_id_token") response.delete_cookie("oauth_id_token")
return RedirectResponse( return RedirectResponse(
url=f"{logout_url}?id_token_hint={oauth_id_token}" headers=response.headers,
url=f"{logout_url}?id_token_hint={oauth_id_token}",
) )
else: else:
raise HTTPException( raise HTTPException(
@ -612,7 +599,7 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)):
admin_email = request.app.state.config.ADMIN_EMAIL admin_email = request.app.state.config.ADMIN_EMAIL
admin_name = None admin_name = None
print(admin_email, admin_name) log.info(f"Admin details - Email: {admin_email}, Name: {admin_name}")
if admin_email: if admin_email:
admin = Users.get_user_by_email(admin_email) admin = Users.get_user_by_email(admin_email)

View file

@ -70,6 +70,12 @@ async def set_direct_connections_config(
# CodeInterpreterConfig # CodeInterpreterConfig
############################ ############################
class CodeInterpreterConfigForm(BaseModel): class CodeInterpreterConfigForm(BaseModel):
CODE_EXECUTION_ENGINE: str
CODE_EXECUTION_JUPYTER_URL: Optional[str]
CODE_EXECUTION_JUPYTER_AUTH: Optional[str]
CODE_EXECUTION_JUPYTER_AUTH_TOKEN: Optional[str]
CODE_EXECUTION_JUPYTER_AUTH_PASSWORD: Optional[str]
CODE_EXECUTION_JUPYTER_TIMEOUT: Optional[int]
ENABLE_CODE_INTERPRETER: bool ENABLE_CODE_INTERPRETER: bool
CODE_INTERPRETER_ENGINE: str CODE_INTERPRETER_ENGINE: str
CODE_INTERPRETER_PROMPT_TEMPLATE: Optional[str] CODE_INTERPRETER_PROMPT_TEMPLATE: Optional[str]
@ -77,11 +83,18 @@ class CodeInterpreterConfigForm(BaseModel):
CODE_INTERPRETER_JUPYTER_AUTH: Optional[str] CODE_INTERPRETER_JUPYTER_AUTH: Optional[str]
CODE_INTERPRETER_JUPYTER_AUTH_TOKEN: Optional[str] CODE_INTERPRETER_JUPYTER_AUTH_TOKEN: Optional[str]
CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD: Optional[str] CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD: Optional[str]
CODE_INTERPRETER_JUPYTER_TIMEOUT: Optional[int]
@router.get("/code_interpreter", response_model=CodeInterpreterConfigForm) @router.get("/code_execution", response_model=CodeInterpreterConfigForm)
async def get_code_interpreter_config(request: Request, user=Depends(get_admin_user)): async def get_code_execution_config(request: Request, user=Depends(get_admin_user)):
return { return {
"CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE,
"CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
"CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
"CODE_EXECUTION_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN,
"CODE_EXECUTION_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD,
"CODE_EXECUTION_JUPYTER_TIMEOUT": request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT,
"ENABLE_CODE_INTERPRETER": request.app.state.config.ENABLE_CODE_INTERPRETER, "ENABLE_CODE_INTERPRETER": request.app.state.config.ENABLE_CODE_INTERPRETER,
"CODE_INTERPRETER_ENGINE": request.app.state.config.CODE_INTERPRETER_ENGINE, "CODE_INTERPRETER_ENGINE": request.app.state.config.CODE_INTERPRETER_ENGINE,
"CODE_INTERPRETER_PROMPT_TEMPLATE": request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE, "CODE_INTERPRETER_PROMPT_TEMPLATE": request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE,
@ -89,13 +102,32 @@ async def get_code_interpreter_config(request: Request, user=Depends(get_admin_u
"CODE_INTERPRETER_JUPYTER_AUTH": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH, "CODE_INTERPRETER_JUPYTER_AUTH": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH,
"CODE_INTERPRETER_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
"CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
"CODE_INTERPRETER_JUPYTER_TIMEOUT": request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
} }
@router.post("/code_interpreter", response_model=CodeInterpreterConfigForm) @router.post("/code_execution", response_model=CodeInterpreterConfigForm)
async def set_code_interpreter_config( async def set_code_execution_config(
request: Request, form_data: CodeInterpreterConfigForm, user=Depends(get_admin_user) request: Request, form_data: CodeInterpreterConfigForm, user=Depends(get_admin_user)
): ):
request.app.state.config.CODE_EXECUTION_ENGINE = form_data.CODE_EXECUTION_ENGINE
request.app.state.config.CODE_EXECUTION_JUPYTER_URL = (
form_data.CODE_EXECUTION_JUPYTER_URL
)
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH = (
form_data.CODE_EXECUTION_JUPYTER_AUTH
)
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = (
form_data.CODE_EXECUTION_JUPYTER_AUTH_TOKEN
)
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = (
form_data.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD
)
request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT = (
form_data.CODE_EXECUTION_JUPYTER_TIMEOUT
)
request.app.state.config.ENABLE_CODE_INTERPRETER = form_data.ENABLE_CODE_INTERPRETER request.app.state.config.ENABLE_CODE_INTERPRETER = form_data.ENABLE_CODE_INTERPRETER
request.app.state.config.CODE_INTERPRETER_ENGINE = form_data.CODE_INTERPRETER_ENGINE request.app.state.config.CODE_INTERPRETER_ENGINE = form_data.CODE_INTERPRETER_ENGINE
request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = ( request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = (
@ -116,8 +148,17 @@ async def set_code_interpreter_config(
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = ( request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = (
form_data.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD form_data.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD
) )
request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = (
form_data.CODE_INTERPRETER_JUPYTER_TIMEOUT
)
return { return {
"CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE,
"CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
"CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
"CODE_EXECUTION_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN,
"CODE_EXECUTION_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD,
"CODE_EXECUTION_JUPYTER_TIMEOUT": request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT,
"ENABLE_CODE_INTERPRETER": request.app.state.config.ENABLE_CODE_INTERPRETER, "ENABLE_CODE_INTERPRETER": request.app.state.config.ENABLE_CODE_INTERPRETER,
"CODE_INTERPRETER_ENGINE": request.app.state.config.CODE_INTERPRETER_ENGINE, "CODE_INTERPRETER_ENGINE": request.app.state.config.CODE_INTERPRETER_ENGINE,
"CODE_INTERPRETER_PROMPT_TEMPLATE": request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE, "CODE_INTERPRETER_PROMPT_TEMPLATE": request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE,
@ -125,6 +166,7 @@ async def set_code_interpreter_config(
"CODE_INTERPRETER_JUPYTER_AUTH": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH, "CODE_INTERPRETER_JUPYTER_AUTH": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH,
"CODE_INTERPRETER_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
"CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
"CODE_INTERPRETER_JUPYTER_TIMEOUT": request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
} }

View file

@ -16,6 +16,7 @@ from open_webui.models.files import (
Files, Files,
) )
from open_webui.routers.retrieval import ProcessFileForm, process_file from open_webui.routers.retrieval import ProcessFileForm, process_file
from open_webui.routers.audio import transcribe
from open_webui.storage.provider import Storage from open_webui.storage.provider import Storage
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from pydantic import BaseModel from pydantic import BaseModel
@ -67,7 +68,22 @@ def upload_file(
) )
try: try:
if file.content_type in [
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/x-m4a",
]:
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path)
process_file(
request,
ProcessFileForm(file_id=id, content=result.get("text", "")),
user=user,
)
else:
process_file(request, ProcessFileForm(file_id=id), user=user) process_file(request, ProcessFileForm(file_id=id), user=user)
file_item = Files.get_file_by_id(id=id) file_item = Files.get_file_by_id(id=id)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
@ -225,17 +241,24 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
filename = file.meta.get("name", file.filename) filename = file.meta.get("name", file.filename)
encoded_filename = quote(filename) # RFC5987 encoding encoded_filename = quote(filename) # RFC5987 encoding
content_type = file.meta.get("content_type")
filename = file.meta.get("name", file.filename)
encoded_filename = quote(filename)
headers = {} headers = {}
if file.meta.get("content_type") not in [
"application/pdf",
"text/plain",
]:
headers = {
**headers,
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
}
return FileResponse(file_path, headers=headers) if content_type == "application/pdf" or filename.lower().endswith(
".pdf"
):
headers["Content-Disposition"] = (
f"inline; filename*=UTF-8''{encoded_filename}"
)
content_type = "application/pdf"
elif content_type != "text/plain":
headers["Content-Disposition"] = (
f"attachment; filename*=UTF-8''{encoded_filename}"
)
return FileResponse(file_path, headers=headers, media_type=content_type)
else: else:
raise HTTPException( raise HTTPException(
@ -266,7 +289,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
# Check if the file already exists in the cache # Check if the file already exists in the cache
if file_path.is_file(): if file_path.is_file():
print(f"file_path: {file_path}") log.info(f"file_path: {file_path}")
return FileResponse(file_path) return FileResponse(file_path)
else: else:
raise HTTPException( raise HTTPException(

View file

@ -1,4 +1,5 @@
import os import os
import logging
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -13,6 +14,11 @@ from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter() router = APIRouter()
@ -79,7 +85,7 @@ async def create_new_function(
detail=ERROR_MESSAGES.DEFAULT("Error creating function"), detail=ERROR_MESSAGES.DEFAULT("Error creating function"),
) )
except Exception as e: except Exception as e:
print(e) log.exception(f"Failed to create a new function: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e), detail=ERROR_MESSAGES.DEFAULT(e),
@ -183,7 +189,7 @@ async def update_function_by_id(
FUNCTIONS[id] = function_module FUNCTIONS[id] = function_module
updated = {**form_data.model_dump(exclude={"id"}), "type": function_type} updated = {**form_data.model_dump(exclude={"id"}), "type": function_type}
print(updated) log.debug(updated)
function = Functions.update_function_by_id(id, updated) function = Functions.update_function_by_id(id, updated)
@ -299,7 +305,7 @@ async def update_function_valves_by_id(
Functions.update_function_valves_by_id(id, valves.model_dump()) Functions.update_function_valves_by_id(id, valves.model_dump())
return valves.model_dump() return valves.model_dump()
except Exception as e: except Exception as e:
print(e) log.exception(f"Error updating function values by id {id}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e), detail=ERROR_MESSAGES.DEFAULT(e),
@ -388,7 +394,7 @@ async def update_function_user_valves_by_id(
) )
return user_valves.model_dump() return user_valves.model_dump()
except Exception as e: except Exception as e:
print(e) log.exception(f"Error updating function user valves by id {id}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e), detail=ERROR_MESSAGES.DEFAULT(e),

16
backend/open_webui/routers/groups.py Normal file → Executable file
View file

@ -1,7 +1,7 @@
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import logging
from open_webui.models.users import Users from open_webui.models.users import Users
from open_webui.models.groups import ( from open_webui.models.groups import (
@ -14,7 +14,13 @@ from open_webui.models.groups import (
from open_webui.config import CACHE_DIR from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter() router = APIRouter()
@ -37,7 +43,7 @@ async def get_groups(user=Depends(get_verified_user)):
@router.post("/create", response_model=Optional[GroupResponse]) @router.post("/create", response_model=Optional[GroupResponse])
async def create_new_function(form_data: GroupForm, user=Depends(get_admin_user)): async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)):
try: try:
group = Groups.insert_new_group(user.id, form_data) group = Groups.insert_new_group(user.id, form_data)
if group: if group:
@ -48,7 +54,7 @@ async def create_new_function(form_data: GroupForm, user=Depends(get_admin_user)
detail=ERROR_MESSAGES.DEFAULT("Error creating group"), detail=ERROR_MESSAGES.DEFAULT("Error creating group"),
) )
except Exception as e: except Exception as e:
print(e) log.exception(f"Error creating a new group: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e), detail=ERROR_MESSAGES.DEFAULT(e),
@ -94,7 +100,7 @@ async def update_group_by_id(
detail=ERROR_MESSAGES.DEFAULT("Error updating group"), detail=ERROR_MESSAGES.DEFAULT("Error updating group"),
) )
except Exception as e: except Exception as e:
print(e) log.exception(f"Error updating group {id}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e), detail=ERROR_MESSAGES.DEFAULT(e),
@ -118,7 +124,7 @@ async def delete_group_by_id(id: str, user=Depends(get_admin_user)):
detail=ERROR_MESSAGES.DEFAULT("Error deleting group"), detail=ERROR_MESSAGES.DEFAULT("Error deleting group"),
) )
except Exception as e: except Exception as e:
print(e) log.exception(f"Error deleting group {id}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e), detail=ERROR_MESSAGES.DEFAULT(e),

View file

@ -55,6 +55,10 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
"COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW, "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW,
"COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES, "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES,
}, },
"gemini": {
"GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
"GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY,
},
} }
@ -78,6 +82,11 @@ class ComfyUIConfigForm(BaseModel):
COMFYUI_WORKFLOW_NODES: list[dict] COMFYUI_WORKFLOW_NODES: list[dict]
class GeminiConfigForm(BaseModel):
GEMINI_API_BASE_URL: str
GEMINI_API_KEY: str
class ConfigForm(BaseModel): class ConfigForm(BaseModel):
enabled: bool enabled: bool
engine: str engine: str
@ -85,6 +94,7 @@ class ConfigForm(BaseModel):
openai: OpenAIConfigForm openai: OpenAIConfigForm
automatic1111: Automatic1111ConfigForm automatic1111: Automatic1111ConfigForm
comfyui: ComfyUIConfigForm comfyui: ComfyUIConfigForm
gemini: GeminiConfigForm
@router.post("/config/update") @router.post("/config/update")
@ -103,6 +113,11 @@ async def update_config(
) )
request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.openai.OPENAI_API_KEY request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.openai.OPENAI_API_KEY
request.app.state.config.IMAGES_GEMINI_API_BASE_URL = (
form_data.gemini.GEMINI_API_BASE_URL
)
request.app.state.config.IMAGES_GEMINI_API_KEY = form_data.gemini.GEMINI_API_KEY
request.app.state.config.AUTOMATIC1111_BASE_URL = ( request.app.state.config.AUTOMATIC1111_BASE_URL = (
form_data.automatic1111.AUTOMATIC1111_BASE_URL form_data.automatic1111.AUTOMATIC1111_BASE_URL
) )
@ -129,6 +144,8 @@ async def update_config(
request.app.state.config.COMFYUI_BASE_URL = ( request.app.state.config.COMFYUI_BASE_URL = (
form_data.comfyui.COMFYUI_BASE_URL.strip("/") form_data.comfyui.COMFYUI_BASE_URL.strip("/")
) )
request.app.state.config.COMFYUI_API_KEY = form_data.comfyui.COMFYUI_API_KEY
request.app.state.config.COMFYUI_WORKFLOW = form_data.comfyui.COMFYUI_WORKFLOW request.app.state.config.COMFYUI_WORKFLOW = form_data.comfyui.COMFYUI_WORKFLOW
request.app.state.config.COMFYUI_WORKFLOW_NODES = ( request.app.state.config.COMFYUI_WORKFLOW_NODES = (
form_data.comfyui.COMFYUI_WORKFLOW_NODES form_data.comfyui.COMFYUI_WORKFLOW_NODES
@ -155,6 +172,10 @@ async def update_config(
"COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW, "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW,
"COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES, "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES,
}, },
"gemini": {
"GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
"GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY,
},
} }
@ -184,9 +205,17 @@ async def verify_url(request: Request, user=Depends(get_admin_user)):
request.app.state.config.ENABLE_IMAGE_GENERATION = False request.app.state.config.ENABLE_IMAGE_GENERATION = False
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
headers = None
if request.app.state.config.COMFYUI_API_KEY:
headers = {
"Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}"
}
try: try:
r = requests.get( r = requests.get(
url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info" url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info",
headers=headers,
) )
r.raise_for_status() r.raise_for_status()
return True return True
@ -224,6 +253,12 @@ def get_image_model(request):
if request.app.state.config.IMAGE_GENERATION_MODEL if request.app.state.config.IMAGE_GENERATION_MODEL
else "dall-e-2" else "dall-e-2"
) )
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini":
return (
request.app.state.config.IMAGE_GENERATION_MODEL
if request.app.state.config.IMAGE_GENERATION_MODEL
else "imagen-3.0-generate-002"
)
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
return ( return (
request.app.state.config.IMAGE_GENERATION_MODEL request.app.state.config.IMAGE_GENERATION_MODEL
@ -299,6 +334,10 @@ def get_models(request: Request, user=Depends(get_verified_user)):
{"id": "dall-e-2", "name": "DALL·E 2"}, {"id": "dall-e-2", "name": "DALL·E 2"},
{"id": "dall-e-3", "name": "DALL·E 3"}, {"id": "dall-e-3", "name": "DALL·E 3"},
] ]
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini":
return [
{"id": "imagen-3-0-generate-002", "name": "imagen-3.0 generate-002"},
]
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
# TODO - get models from comfyui # TODO - get models from comfyui
headers = { headers = {
@ -322,7 +361,7 @@ def get_models(request: Request, user=Depends(get_verified_user)):
if model_node_id: if model_node_id:
model_list_key = None model_list_key = None
print(workflow[model_node_id]["class_type"]) log.info(workflow[model_node_id]["class_type"])
for key in info[workflow[model_node_id]["class_type"]]["input"][ for key in info[workflow[model_node_id]["class_type"]]["input"][
"required" "required"
]: ]:
@ -483,6 +522,41 @@ async def image_generations(
images.append({"url": url}) images.append({"url": url})
return images return images
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini":
headers = {}
headers["Content-Type"] = "application/json"
headers["x-goog-api-key"] = request.app.state.config.IMAGES_GEMINI_API_KEY
model = get_image_model(request)
data = {
"instances": {"prompt": form_data.prompt},
"parameters": {
"sampleCount": form_data.n,
"outputOptions": {"mimeType": "image/png"},
},
}
# Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread(
requests.post,
url=f"{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}:predict",
json=data,
headers=headers,
)
r.raise_for_status()
res = r.json()
images = []
for image in res["predictions"]:
image_data, content_type = load_b64_image_data(
image["bytesBase64Encoded"]
)
url = upload_image(request, data, image_data, content_type, user)
images.append({"url": url})
return images
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
data = { data = {
"prompt": form_data.prompt, "prompt": form_data.prompt,

View file

@ -614,7 +614,7 @@ def add_files_to_knowledge_batch(
) )
# Get files content # Get files content
print(f"files/batch/add - {len(form_data)} files") log.info(f"files/batch/add - {len(form_data)} files")
files: List[FileModel] = [] files: List[FileModel] = []
for form in form_data: for form in form_data:
file = Files.get_file_by_id(form.file_id) file = Files.get_file_by_id(form.file_id)

View file

@ -14,6 +14,11 @@ from urllib.parse import urlparse
import aiohttp import aiohttp
from aiocache import cached from aiocache import cached
import requests import requests
from open_webui.models.users import UserModel
from open_webui.env import (
ENABLE_FORWARD_USER_INFO_HEADERS,
)
from fastapi import ( from fastapi import (
Depends, Depends,
@ -26,7 +31,7 @@ from fastapi import (
) )
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, validator
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
@ -66,12 +71,26 @@ log.setLevel(SRC_LOG_LEVELS["OLLAMA"])
########################################## ##########################################
async def send_get_request(url, key=None): 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_OPENAI_MODEL_LIST)
try: try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get( async with session.get(
url, headers={**({"Authorization": f"Bearer {key}"} if key else {})} url,
headers={
"Content-Type": "application/json",
**({"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,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
},
) as response: ) as response:
return await response.json() return await response.json()
except Exception as e: except Exception as e:
@ -96,6 +115,7 @@ async def send_post_request(
stream: bool = True, stream: bool = True,
key: Optional[str] = None, key: Optional[str] = None,
content_type: Optional[str] = None, content_type: Optional[str] = None,
user: UserModel = None,
): ):
r = None r = None
@ -110,6 +130,16 @@ async def send_post_request(
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}), **({"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,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
}, },
) )
r.raise_for_status() r.raise_for_status()
@ -191,7 +221,19 @@ async def verify_connection(
try: try:
async with session.get( async with session.get(
f"{url}/api/version", f"{url}/api/version",
headers={**({"Authorization": f"Bearer {key}"} if key else {})}, headers={
**({"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,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
},
) as r: ) as r:
if r.status != 200: if r.status != 200:
detail = f"HTTP Error: {r.status}" detail = f"HTTP Error: {r.status}"
@ -254,7 +296,7 @@ async def update_config(
@cached(ttl=3) @cached(ttl=3)
async def get_all_models(request: Request): async def get_all_models(request: Request, user: UserModel = None):
log.info("get_all_models()") log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API: if request.app.state.config.ENABLE_OLLAMA_API:
request_tasks = [] request_tasks = []
@ -262,7 +304,7 @@ async def get_all_models(request: Request):
if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and ( if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and (
url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support
): ):
request_tasks.append(send_get_request(f"{url}/api/tags")) request_tasks.append(send_get_request(f"{url}/api/tags", user=user))
else: else:
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx), str(idx),
@ -275,7 +317,9 @@ async def get_all_models(request: Request):
key = api_config.get("key", None) key = api_config.get("key", None)
if enable: if enable:
request_tasks.append(send_get_request(f"{url}/api/tags", key)) request_tasks.append(
send_get_request(f"{url}/api/tags", key, user=user)
)
else: else:
request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None)))
@ -360,7 +404,7 @@ async def get_ollama_tags(
models = [] models = []
if url_idx is None: if url_idx is None:
models = await get_all_models(request) models = await get_all_models(request, user=user)
else: else:
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
@ -370,7 +414,19 @@ async def get_ollama_tags(
r = requests.request( r = requests.request(
method="GET", method="GET",
url=f"{url}/api/tags", url=f"{url}/api/tags",
headers={**({"Authorization": f"Bearer {key}"} if key else {})}, headers={
**({"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,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
},
) )
r.raise_for_status() r.raise_for_status()
@ -477,6 +533,7 @@ async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_u
url, {} url, {}
), # Legacy support ), # Legacy support
).get("key", None), ).get("key", None),
user=user,
) )
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS) for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
] ]
@ -509,6 +566,7 @@ async def pull_model(
url=f"{url}/api/pull", url=f"{url}/api/pull",
payload=json.dumps(payload), payload=json.dumps(payload),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
) )
@ -527,7 +585,7 @@ async def push_model(
user=Depends(get_admin_user), user=Depends(get_admin_user),
): ):
if url_idx is None: if url_idx is None:
await get_all_models(request) await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS models = request.app.state.OLLAMA_MODELS
if form_data.name in models: if form_data.name in models:
@ -545,6 +603,7 @@ async def push_model(
url=f"{url}/api/push", url=f"{url}/api/push",
payload=form_data.model_dump_json(exclude_none=True).encode(), payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
) )
@ -571,6 +630,7 @@ async def create_model(
url=f"{url}/api/create", url=f"{url}/api/create",
payload=form_data.model_dump_json(exclude_none=True).encode(), payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
) )
@ -588,7 +648,7 @@ async def copy_model(
user=Depends(get_admin_user), user=Depends(get_admin_user),
): ):
if url_idx is None: if url_idx is None:
await get_all_models(request) await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS models = request.app.state.OLLAMA_MODELS
if form_data.source in models: if form_data.source in models:
@ -609,6 +669,16 @@ async def copy_model(
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}), **({"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,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
}, },
data=form_data.model_dump_json(exclude_none=True).encode(), data=form_data.model_dump_json(exclude_none=True).encode(),
) )
@ -643,7 +713,7 @@ async def delete_model(
user=Depends(get_admin_user), user=Depends(get_admin_user),
): ):
if url_idx is None: if url_idx is None:
await get_all_models(request) await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS models = request.app.state.OLLAMA_MODELS
if form_data.name in models: if form_data.name in models:
@ -665,6 +735,16 @@ async def delete_model(
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}), **({"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,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
}, },
) )
r.raise_for_status() r.raise_for_status()
@ -693,7 +773,7 @@ async def delete_model(
async def show_model_info( async def show_model_info(
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user) request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
): ):
await get_all_models(request) await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS models = request.app.state.OLLAMA_MODELS
if form_data.name not in models: if form_data.name not in models:
@ -714,6 +794,16 @@ async def show_model_info(
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}), **({"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,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
}, },
data=form_data.model_dump_json(exclude_none=True).encode(), data=form_data.model_dump_json(exclude_none=True).encode(),
) )
@ -757,7 +847,7 @@ async def embed(
log.info(f"generate_ollama_batch_embeddings {form_data}") log.info(f"generate_ollama_batch_embeddings {form_data}")
if url_idx is None: if url_idx is None:
await get_all_models(request) await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS models = request.app.state.OLLAMA_MODELS
model = form_data.model model = form_data.model
@ -783,6 +873,16 @@ async def embed(
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}), **({"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,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
}, },
data=form_data.model_dump_json(exclude_none=True).encode(), data=form_data.model_dump_json(exclude_none=True).encode(),
) )
@ -826,7 +926,7 @@ async def embeddings(
log.info(f"generate_ollama_embeddings {form_data}") log.info(f"generate_ollama_embeddings {form_data}")
if url_idx is None: if url_idx is None:
await get_all_models(request) await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS models = request.app.state.OLLAMA_MODELS
model = form_data.model model = form_data.model
@ -852,6 +952,16 @@ async def embeddings(
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {}), **({"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,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
}, },
data=form_data.model_dump_json(exclude_none=True).encode(), data=form_data.model_dump_json(exclude_none=True).encode(),
) )
@ -901,7 +1011,7 @@ async def generate_completion(
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
if url_idx is None: if url_idx is None:
await get_all_models(request) await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS models = request.app.state.OLLAMA_MODELS
model = form_data.model model = form_data.model
@ -931,15 +1041,29 @@ async def generate_completion(
url=f"{url}/api/generate", url=f"{url}/api/generate",
payload=form_data.model_dump_json(exclude_none=True).encode(), payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
) )
class ChatMessage(BaseModel): class ChatMessage(BaseModel):
role: str role: str
content: str content: Optional[str] = None
tool_calls: Optional[list[dict]] = None tool_calls: Optional[list[dict]] = None
images: Optional[list[str]] = None images: Optional[list[str]] = None
@validator("content", pre=True)
@classmethod
def check_at_least_one_field(cls, field_value, values, **kwargs):
# Raise an error if both 'content' and 'tool_calls' are None
if field_value is None and (
"tool_calls" not in values or values["tool_calls"] is None
):
raise ValueError(
"At least one of 'content' or 'tool_calls' must be provided"
)
return field_value
class GenerateChatCompletionForm(BaseModel): class GenerateChatCompletionForm(BaseModel):
model: str model: str
@ -1047,6 +1171,7 @@ async def generate_chat_completion(
stream=form_data.stream, stream=form_data.stream,
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
content_type="application/x-ndjson", content_type="application/x-ndjson",
user=user,
) )
@ -1149,6 +1274,7 @@ async def generate_openai_completion(
payload=json.dumps(payload), payload=json.dumps(payload),
stream=payload.get("stream", False), stream=payload.get("stream", False),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
) )
@ -1227,6 +1353,7 @@ async def generate_openai_chat_completion(
payload=json.dumps(payload), payload=json.dumps(payload),
stream=payload.get("stream", False), stream=payload.get("stream", False),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
) )
@ -1240,7 +1367,7 @@ async def get_openai_models(
models = [] models = []
if url_idx is None: if url_idx is None:
model_list = await get_all_models(request) model_list = await get_all_models(request, user=user)
models = [ models = [
{ {
"id": model["model"], "id": model["model"],

View file

@ -26,6 +26,7 @@ from open_webui.env import (
ENABLE_FORWARD_USER_INFO_HEADERS, ENABLE_FORWARD_USER_INFO_HEADERS,
BYPASS_MODEL_ACCESS_CONTROL, BYPASS_MODEL_ACCESS_CONTROL,
) )
from open_webui.models.users import UserModel
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENV, SRC_LOG_LEVELS from open_webui.env import ENV, SRC_LOG_LEVELS
@ -51,12 +52,25 @@ log.setLevel(SRC_LOG_LEVELS["OPENAI"])
########################################## ##########################################
async def send_get_request(url, key=None): 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_OPENAI_MODEL_LIST)
try: try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get( async with session.get(
url, headers={**({"Authorization": f"Bearer {key}"} if key else {})} url,
headers={
**({"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,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
},
) as response: ) as response:
return await response.json() return await response.json()
except Exception as e: except Exception as e:
@ -84,9 +98,15 @@ def openai_o1_o3_handler(payload):
payload["max_completion_tokens"] = payload["max_tokens"] payload["max_completion_tokens"] = payload["max_tokens"]
del payload["max_tokens"] del payload["max_tokens"]
# Fix: O1 does not support the "system" parameter, Modify "system" to "user" # Fix: o1 and o3 do not support the "system" role directly.
# For older models like "o1-mini" or "o1-preview", use role "user".
# For newer o1/o3 models, replace "system" with "developer".
if payload["messages"][0]["role"] == "system": if payload["messages"][0]["role"] == "system":
model_lower = payload["model"].lower()
if model_lower.startswith("o1-mini") or model_lower.startswith("o1-preview"):
payload["messages"][0]["role"] = "user" payload["messages"][0]["role"] = "user"
else:
payload["messages"][0]["role"] = "developer"
return payload return payload
@ -247,7 +267,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.OPENAI_NOT_FOUND) raise HTTPException(status_code=401, detail=ERROR_MESSAGES.OPENAI_NOT_FOUND)
async def get_all_models_responses(request: Request) -> list: async def get_all_models_responses(request: Request, user: UserModel) -> list:
if not request.app.state.config.ENABLE_OPENAI_API: if not request.app.state.config.ENABLE_OPENAI_API:
return [] return []
@ -271,7 +291,9 @@ async def get_all_models_responses(request: Request) -> list:
): ):
request_tasks.append( request_tasks.append(
send_get_request( send_get_request(
f"{url}/models", request.app.state.config.OPENAI_API_KEYS[idx] f"{url}/models",
request.app.state.config.OPENAI_API_KEYS[idx],
user=user,
) )
) )
else: else:
@ -291,6 +313,7 @@ async def get_all_models_responses(request: Request) -> list:
send_get_request( send_get_request(
f"{url}/models", f"{url}/models",
request.app.state.config.OPENAI_API_KEYS[idx], request.app.state.config.OPENAI_API_KEYS[idx],
user=user,
) )
) )
else: else:
@ -352,13 +375,13 @@ async def get_filtered_models(models, user):
@cached(ttl=3) @cached(ttl=3)
async def get_all_models(request: Request) -> dict[str, list]: async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
log.info("get_all_models()") log.info("get_all_models()")
if not request.app.state.config.ENABLE_OPENAI_API: if not request.app.state.config.ENABLE_OPENAI_API:
return {"data": []} return {"data": []}
responses = await get_all_models_responses(request) responses = await get_all_models_responses(request, user=user)
def extract_data(response): def extract_data(response):
if response and "data" in response: if response and "data" in response:
@ -418,7 +441,7 @@ async def get_models(
} }
if url_idx is None: if url_idx is None:
models = await get_all_models(request) models = await get_all_models(request, user=user)
else: else:
url = request.app.state.config.OPENAI_API_BASE_URLS[url_idx] url = request.app.state.config.OPENAI_API_BASE_URLS[url_idx]
key = request.app.state.config.OPENAI_API_KEYS[url_idx] key = request.app.state.config.OPENAI_API_KEYS[url_idx]
@ -515,6 +538,16 @@ async def verify_connection(
headers={ headers={
"Authorization": f"Bearer {key}", "Authorization": f"Bearer {key}",
"Content-Type": "application/json", "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,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}, },
) as r: ) as r:
if r.status != 200: if r.status != 200:
@ -587,7 +620,7 @@ async def generate_chat_completion(
detail="Model not found", detail="Model not found",
) )
await get_all_models(request) await get_all_models(request, user=user)
model = request.app.state.OPENAI_MODELS.get(model_id) model = request.app.state.OPENAI_MODELS.get(model_id)
if model: if model:
idx = model["urlIdx"] idx = model["urlIdx"]
@ -777,7 +810,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
if r is not None: if r is not None:
try: try:
res = await r.json() res = await r.json()
print(res) log.error(res)
if "error" in res: if "error" in res:
detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}" detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
except Exception: except Exception:

View file

@ -101,7 +101,7 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
if "detail" in res: if "detail" in res:
raise Exception(response.status, res["detail"]) raise Exception(response.status, res["detail"])
except Exception as e: except Exception as e:
print(f"Connection error: {e}") log.exception(f"Connection error: {e}")
return payload return payload
@ -153,7 +153,7 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
except Exception: except Exception:
pass pass
except Exception as e: except Exception as e:
print(f"Connection error: {e}") log.exception(f"Connection error: {e}")
return payload return payload
@ -169,7 +169,7 @@ router = APIRouter()
@router.get("/list") @router.get("/list")
async def get_pipelines_list(request: Request, user=Depends(get_admin_user)): async def get_pipelines_list(request: Request, user=Depends(get_admin_user)):
responses = await get_all_models_responses(request) responses = await get_all_models_responses(request, user)
log.debug(f"get_pipelines_list: get_openai_models_responses returned {responses}") log.debug(f"get_pipelines_list: get_openai_models_responses returned {responses}")
urlIdxs = [ urlIdxs = [
@ -196,7 +196,7 @@ async def upload_pipeline(
file: UploadFile = File(...), file: UploadFile = File(...),
user=Depends(get_admin_user), user=Depends(get_admin_user),
): ):
print("upload_pipeline", urlIdx, file.filename) log.info(f"upload_pipeline: urlIdx={urlIdx}, filename={file.filename}")
# Check if the uploaded file is a python file # Check if the uploaded file is a python file
if not (file.filename and file.filename.endswith(".py")): if not (file.filename and file.filename.endswith(".py")):
raise HTTPException( raise HTTPException(
@ -231,7 +231,7 @@ async def upload_pipeline(
return {**data} return {**data}
except Exception as e: except Exception as e:
# Handle connection error here # Handle connection error here
print(f"Connection error: {e}") log.exception(f"Connection error: {e}")
detail = None detail = None
status_code = status.HTTP_404_NOT_FOUND status_code = status.HTTP_404_NOT_FOUND
@ -282,7 +282,7 @@ async def add_pipeline(
return {**data} return {**data}
except Exception as e: except Exception as e:
# Handle connection error here # Handle connection error here
print(f"Connection error: {e}") log.exception(f"Connection error: {e}")
detail = None detail = None
if r is not None: if r is not None:
@ -327,7 +327,7 @@ async def delete_pipeline(
return {**data} return {**data}
except Exception as e: except Exception as e:
# Handle connection error here # Handle connection error here
print(f"Connection error: {e}") log.exception(f"Connection error: {e}")
detail = None detail = None
if r is not None: if r is not None:
@ -361,7 +361,7 @@ async def get_pipelines(
return {**data} return {**data}
except Exception as e: except Exception as e:
# Handle connection error here # Handle connection error here
print(f"Connection error: {e}") log.exception(f"Connection error: {e}")
detail = None detail = None
if r is not None: if r is not None:
@ -400,7 +400,7 @@ async def get_pipeline_valves(
return {**data} return {**data}
except Exception as e: except Exception as e:
# Handle connection error here # Handle connection error here
print(f"Connection error: {e}") log.exception(f"Connection error: {e}")
detail = None detail = None
if r is not None: if r is not None:
@ -440,7 +440,7 @@ async def get_pipeline_valves_spec(
return {**data} return {**data}
except Exception as e: except Exception as e:
# Handle connection error here # Handle connection error here
print(f"Connection error: {e}") log.exception(f"Connection error: {e}")
detail = None detail = None
if r is not None: if r is not None:
@ -482,7 +482,7 @@ async def update_pipeline_valves(
return {**data} return {**data}
except Exception as e: except Exception as e:
# Handle connection error here # Handle connection error here
print(f"Connection error: {e}") log.exception(f"Connection error: {e}")
detail = None detail = None

View file

@ -351,10 +351,17 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
return { return {
"status": True, "status": True,
"pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES, "pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES,
"RAG_FULL_CONTEXT": request.app.state.config.RAG_FULL_CONTEXT,
"BYPASS_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL,
"enable_google_drive_integration": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, "enable_google_drive_integration": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
"content_extraction": { "content_extraction": {
"engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE, "engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"tika_server_url": request.app.state.config.TIKA_SERVER_URL, "tika_server_url": request.app.state.config.TIKA_SERVER_URL,
"document_intelligence_config": {
"endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
},
}, },
"chunk": { "chunk": {
"text_splitter": request.app.state.config.TEXT_SPLITTER, "text_splitter": request.app.state.config.TEXT_SPLITTER,
@ -371,10 +378,12 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"proxy_url": request.app.state.config.YOUTUBE_LOADER_PROXY_URL, "proxy_url": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
}, },
"web": { "web": {
"web_loader_ssl_verification": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, "ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"search": { "search": {
"enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH, "enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH,
"drive": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, "drive": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"onedrive": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
"engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE, "engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE,
"searxng_query_url": request.app.state.config.SEARXNG_QUERY_URL, "searxng_query_url": request.app.state.config.SEARXNG_QUERY_URL,
"google_pse_api_key": request.app.state.config.GOOGLE_PSE_API_KEY, "google_pse_api_key": request.app.state.config.GOOGLE_PSE_API_KEY,
@ -397,6 +406,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, "bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
"exa_api_key": request.app.state.config.EXA_API_KEY, "exa_api_key": request.app.state.config.EXA_API_KEY,
"result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, "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, "concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
"domain_filter_list": request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, "domain_filter_list": request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
}, },
@ -409,9 +419,15 @@ class FileConfig(BaseModel):
max_count: Optional[int] = None max_count: Optional[int] = None
class DocumentIntelligenceConfigForm(BaseModel):
endpoint: str
key: str
class ContentExtractionConfig(BaseModel): class ContentExtractionConfig(BaseModel):
engine: str = "" engine: str = ""
tika_server_url: Optional[str] = None tika_server_url: Optional[str] = None
document_intelligence_config: Optional[DocumentIntelligenceConfigForm] = None
class ChunkParamUpdateForm(BaseModel): class ChunkParamUpdateForm(BaseModel):
@ -457,12 +473,16 @@ class WebSearchConfig(BaseModel):
class WebConfig(BaseModel): class WebConfig(BaseModel):
search: WebSearchConfig search: WebSearchConfig
web_loader_ssl_verification: Optional[bool] = None ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
class ConfigUpdateForm(BaseModel): class ConfigUpdateForm(BaseModel):
RAG_FULL_CONTEXT: Optional[bool] = None
BYPASS_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
pdf_extract_images: Optional[bool] = None pdf_extract_images: Optional[bool] = None
enable_google_drive_integration: Optional[bool] = None enable_google_drive_integration: Optional[bool] = None
enable_onedrive_integration: Optional[bool] = None
file: Optional[FileConfig] = None file: Optional[FileConfig] = None
content_extraction: Optional[ContentExtractionConfig] = None content_extraction: Optional[ContentExtractionConfig] = None
chunk: Optional[ChunkParamUpdateForm] = None chunk: Optional[ChunkParamUpdateForm] = None
@ -480,24 +500,51 @@ async def update_rag_config(
else request.app.state.config.PDF_EXTRACT_IMAGES else request.app.state.config.PDF_EXTRACT_IMAGES
) )
request.app.state.config.RAG_FULL_CONTEXT = (
form_data.RAG_FULL_CONTEXT
if form_data.RAG_FULL_CONTEXT is not None
else request.app.state.config.RAG_FULL_CONTEXT
)
request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL = (
form_data.BYPASS_EMBEDDING_AND_RETRIEVAL
if form_data.BYPASS_EMBEDDING_AND_RETRIEVAL is not None
else request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
)
request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ( request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = (
form_data.enable_google_drive_integration form_data.enable_google_drive_integration
if form_data.enable_google_drive_integration is not None if form_data.enable_google_drive_integration is not None
else request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION else request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION
) )
request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION = (
form_data.enable_onedrive_integration
if form_data.enable_onedrive_integration is not None
else request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION
)
if form_data.file is not None: if form_data.file is not None:
request.app.state.config.FILE_MAX_SIZE = form_data.file.max_size request.app.state.config.FILE_MAX_SIZE = form_data.file.max_size
request.app.state.config.FILE_MAX_COUNT = form_data.file.max_count request.app.state.config.FILE_MAX_COUNT = form_data.file.max_count
if form_data.content_extraction is not None: if form_data.content_extraction is not None:
log.info(f"Updating text settings: {form_data.content_extraction}") log.info(
f"Updating content extraction: {request.app.state.config.CONTENT_EXTRACTION_ENGINE} to {form_data.content_extraction.engine}"
)
request.app.state.config.CONTENT_EXTRACTION_ENGINE = ( request.app.state.config.CONTENT_EXTRACTION_ENGINE = (
form_data.content_extraction.engine form_data.content_extraction.engine
) )
request.app.state.config.TIKA_SERVER_URL = ( request.app.state.config.TIKA_SERVER_URL = (
form_data.content_extraction.tika_server_url form_data.content_extraction.tika_server_url
) )
if form_data.content_extraction.document_intelligence_config is not None:
request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = (
form_data.content_extraction.document_intelligence_config.endpoint
)
request.app.state.config.DOCUMENT_INTELLIGENCE_KEY = (
form_data.content_extraction.document_intelligence_config.key
)
if form_data.chunk is not None: if form_data.chunk is not None:
request.app.state.config.TEXT_SPLITTER = form_data.chunk.text_splitter request.app.state.config.TEXT_SPLITTER = form_data.chunk.text_splitter
@ -512,11 +559,16 @@ async def update_rag_config(
if form_data.web is not None: if form_data.web is not None:
request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = (
# Note: When UI "Bypass SSL verification for Websites"=True then ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION=False # Note: When UI "Bypass SSL verification for Websites"=True then ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION=False
form_data.web.web_loader_ssl_verification form_data.web.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION
) )
request.app.state.config.ENABLE_RAG_WEB_SEARCH = form_data.web.search.enabled request.app.state.config.ENABLE_RAG_WEB_SEARCH = form_data.web.search.enabled
request.app.state.config.RAG_WEB_SEARCH_ENGINE = form_data.web.search.engine request.app.state.config.RAG_WEB_SEARCH_ENGINE = form_data.web.search.engine
request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
)
request.app.state.config.SEARXNG_QUERY_URL = ( request.app.state.config.SEARXNG_QUERY_URL = (
form_data.web.search.searxng_query_url form_data.web.search.searxng_query_url
) )
@ -581,6 +633,8 @@ async def update_rag_config(
return { return {
"status": True, "status": True,
"pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES, "pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES,
"RAG_FULL_CONTEXT": request.app.state.config.RAG_FULL_CONTEXT,
"BYPASS_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL,
"file": { "file": {
"max_size": request.app.state.config.FILE_MAX_SIZE, "max_size": request.app.state.config.FILE_MAX_SIZE,
"max_count": request.app.state.config.FILE_MAX_COUNT, "max_count": request.app.state.config.FILE_MAX_COUNT,
@ -588,6 +642,10 @@ async def update_rag_config(
"content_extraction": { "content_extraction": {
"engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE, "engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"tika_server_url": request.app.state.config.TIKA_SERVER_URL, "tika_server_url": request.app.state.config.TIKA_SERVER_URL,
"document_intelligence_config": {
"endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
},
}, },
"chunk": { "chunk": {
"text_splitter": request.app.state.config.TEXT_SPLITTER, "text_splitter": request.app.state.config.TEXT_SPLITTER,
@ -600,7 +658,8 @@ async def update_rag_config(
"translation": request.app.state.YOUTUBE_LOADER_TRANSLATION, "translation": request.app.state.YOUTUBE_LOADER_TRANSLATION,
}, },
"web": { "web": {
"web_loader_ssl_verification": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, "ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"search": { "search": {
"enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH, "enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH,
"engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE, "engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE,
@ -863,7 +922,12 @@ def process_file(
# Update the content in the file # Update the content in the file
# Usage: /files/{file_id}/data/content/update # Usage: /files/{file_id}/data/content/update
try:
# /files/{file_id}/data/content/update
VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}") VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}")
except:
# Audio file upload pipeline
pass
docs = [ docs = [
Document( Document(
@ -920,6 +984,8 @@ def process_file(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE, engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL, TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES, PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
) )
docs = loader.load( docs = loader.load(
file.filename, file.meta.get("content_type"), file_path file.filename, file.meta.get("content_type"), file_path
@ -962,6 +1028,7 @@ def process_file(
hash = calculate_sha256_string(text_content) hash = calculate_sha256_string(text_content)
Files.update_file_hash_by_id(file.id, hash) Files.update_file_hash_by_id(file.id, hash)
if not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
try: try:
result = save_docs_to_vector_db( result = save_docs_to_vector_db(
request, request,
@ -992,6 +1059,14 @@ def process_file(
} }
except Exception as e: except Exception as e:
raise e raise e
else:
return {
"status": True,
"collection_name": None,
"filename": file.filename,
"content": text_content,
}
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
if "No pandoc was found" in str(e): if "No pandoc was found" in str(e):
@ -1262,6 +1337,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
request.app.state.config.TAVILY_API_KEY, request.app.state.config.TAVILY_API_KEY,
query, query,
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
) )
else: else:
raise Exception("No TAVILY_API_KEY found in environment variables") raise Exception("No TAVILY_API_KEY found in environment variables")
@ -1349,6 +1425,22 @@ async def process_web_search(
trust_env=request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV, trust_env=request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV,
) )
docs = await loader.aload() docs = await loader.aload()
if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
return {
"status": True,
"collection_name": None,
"filenames": urls,
"docs": [
{
"content": doc.page_content,
"metadata": doc.metadata,
}
for doc in docs
],
"loaded_count": len(docs),
}
else:
await run_in_threadpool( await run_in_threadpool(
save_docs_to_vector_db, save_docs_to_vector_db,
request, request,
@ -1520,11 +1612,11 @@ def reset_upload_dir(user=Depends(get_admin_user)) -> bool:
elif os.path.isdir(file_path): elif os.path.isdir(file_path):
shutil.rmtree(file_path) # Remove the directory shutil.rmtree(file_path) # Remove the directory
except Exception as e: except Exception as e:
print(f"Failed to delete {file_path}. Reason: {e}") log.exception(f"Failed to delete {file_path}. Reason: {e}")
else: else:
print(f"The directory {folder} does not exist") log.warning(f"The directory {folder} does not exist")
except Exception as e: except Exception as e:
print(f"Failed to process the directory {folder}. Reason: {e}") log.exception(f"Failed to process the directory {folder}. Reason: {e}")
return True return True

View file

@ -20,6 +20,10 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.constants import TASKS from open_webui.constants import TASKS
from open_webui.routers.pipelines import process_pipeline_inlet_filter from open_webui.routers.pipelines import process_pipeline_inlet_filter
from open_webui.utils.filter import (
get_sorted_filter_ids,
process_filter_functions,
)
from open_webui.utils.task import get_task_model_id from open_webui.utils.task import get_task_model_id
from open_webui.config import ( from open_webui.config import (
@ -208,7 +212,7 @@ async def generate_title(
"stream": False, "stream": False,
**( **(
{"max_tokens": 1000} {"max_tokens": 1000}
if models[task_model_id]["owned_by"] == "ollama" if models[task_model_id].get("owned_by") == "ollama"
else { else {
"max_completion_tokens": 1000, "max_completion_tokens": 1000,
} }
@ -221,6 +225,12 @@ async def generate_title(
}, },
} }
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try: try:
return await generate_chat_completion(request, form_data=payload, user=user) return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e: except Exception as e:
@ -290,6 +300,12 @@ async def generate_chat_tags(
}, },
} }
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try: try:
return await generate_chat_completion(request, form_data=payload, user=user) return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e: except Exception as e:
@ -356,6 +372,12 @@ async def generate_image_prompt(
}, },
} }
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try: try:
return await generate_chat_completion(request, form_data=payload, user=user) return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e: except Exception as e:
@ -433,6 +455,12 @@ async def generate_queries(
}, },
} }
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try: try:
return await generate_chat_completion(request, form_data=payload, user=user) return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e: except Exception as e:
@ -514,6 +542,12 @@ async def generate_autocompletion(
}, },
} }
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try: try:
return await generate_chat_completion(request, form_data=payload, user=user) return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e: except Exception as e:
@ -571,7 +605,7 @@ async def generate_emoji(
"stream": False, "stream": False,
**( **(
{"max_tokens": 4} {"max_tokens": 4}
if models[task_model_id]["owned_by"] == "ollama" if models[task_model_id].get("owned_by") == "ollama"
else { else {
"max_completion_tokens": 4, "max_completion_tokens": 4,
} }
@ -584,6 +618,12 @@ async def generate_emoji(
}, },
} }
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try: try:
return await generate_chat_completion(request, form_data=payload, user=user) return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e: except Exception as e:
@ -644,6 +684,12 @@ async def generate_moa_response(
}, },
} }
# Process the payload through the pipeline
try:
payload = await process_pipeline_inlet_filter(request, payload, user, models)
except Exception as e:
raise e
try: try:
return await generate_chat_completion(request, form_data=payload, user=user) return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e: except Exception as e:

View file

@ -1,3 +1,4 @@
import logging
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -15,6 +16,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.tools import get_tools_specs from open_webui.utils.tools import get_tools_specs
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter() router = APIRouter()
@ -111,7 +116,7 @@ async def create_new_tools(
detail=ERROR_MESSAGES.DEFAULT("Error creating tools"), detail=ERROR_MESSAGES.DEFAULT("Error creating tools"),
) )
except Exception as e: except Exception as e:
print(e) log.exception(f"Failed to load the tool by id {form_data.id}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)), detail=ERROR_MESSAGES.DEFAULT(str(e)),
@ -193,7 +198,7 @@ async def update_tools_by_id(
"specs": specs, "specs": specs,
} }
print(updated) log.debug(updated)
tools = Tools.update_tool_by_id(id, updated) tools = Tools.update_tool_by_id(id, updated)
if tools: if tools:
@ -343,7 +348,7 @@ async def update_tools_valves_by_id(
Tools.update_tool_valves_by_id(id, valves.model_dump()) Tools.update_tool_valves_by_id(id, valves.model_dump())
return valves.model_dump() return valves.model_dump()
except Exception as e: except Exception as e:
print(e) log.exception(f"Failed to update tool valves by id {id}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)), detail=ERROR_MESSAGES.DEFAULT(str(e)),
@ -421,7 +426,7 @@ async def update_tools_user_valves_by_id(
) )
return user_valves.model_dump() return user_valves.model_dump()
except Exception as e: except Exception as e:
print(e) log.exception(f"Failed to update user valves by id {id}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(str(e)), detail=ERROR_MESSAGES.DEFAULT(str(e)),

View file

@ -1,48 +1,84 @@
import black import black
import logging
import markdown import markdown
from open_webui.models.chats import ChatTitleMessagesForm from open_webui.models.chats import ChatTitleMessagesForm
from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel from pydantic import BaseModel
from starlette.responses import FileResponse from starlette.responses import FileResponse
from open_webui.utils.misc import get_gravatar_url from open_webui.utils.misc import get_gravatar_url
from open_webui.utils.pdf_generator import PDFGenerator from open_webui.utils.pdf_generator import PDFGenerator
from open_webui.utils.auth import get_admin_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter() router = APIRouter()
@router.get("/gravatar") @router.get("/gravatar")
async def get_gravatar( async def get_gravatar(email: str, user=Depends(get_verified_user)):
email: str,
):
return get_gravatar_url(email) return get_gravatar_url(email)
class CodeFormatRequest(BaseModel): class CodeForm(BaseModel):
code: str code: str
@router.post("/code/format") @router.post("/code/format")
async def format_code(request: CodeFormatRequest): async def format_code(form_data: CodeForm, user=Depends(get_verified_user)):
try: try:
formatted_code = black.format_str(request.code, mode=black.Mode()) formatted_code = black.format_str(form_data.code, mode=black.Mode())
return {"code": formatted_code} return {"code": formatted_code}
except black.NothingChanged: except black.NothingChanged:
return {"code": request.code} return {"code": form_data.code}
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/code/execute")
async def execute_code(
request: Request, form_data: CodeForm, user=Depends(get_verified_user)
):
if request.app.state.config.CODE_EXECUTION_ENGINE == "jupyter":
output = await execute_code_jupyter(
request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
form_data.code,
(
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN
if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == "token"
else None
),
(
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD
if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == "password"
else None
),
request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT,
)
return output
else:
raise HTTPException(
status_code=400,
detail="Code execution engine not supported",
)
class MarkdownForm(BaseModel): class MarkdownForm(BaseModel):
md: str md: str
@router.post("/markdown") @router.post("/markdown")
async def get_html_from_markdown( async def get_html_from_markdown(
form_data: MarkdownForm, form_data: MarkdownForm, user=Depends(get_verified_user)
): ):
return {"html": markdown.markdown(form_data.md)} return {"html": markdown.markdown(form_data.md)}
@ -54,7 +90,7 @@ class ChatForm(BaseModel):
@router.post("/pdf") @router.post("/pdf")
async def download_chat_as_pdf( async def download_chat_as_pdf(
form_data: ChatTitleMessagesForm, form_data: ChatTitleMessagesForm, user=Depends(get_verified_user)
): ):
try: try:
pdf_bytes = PDFGenerator(form_data).generate_chat_pdf() pdf_bytes = PDFGenerator(form_data).generate_chat_pdf()
@ -65,7 +101,7 @@ async def download_chat_as_pdf(
headers={"Content-Disposition": "attachment;filename=chat.pdf"}, headers={"Content-Disposition": "attachment;filename=chat.pdf"},
) )
except Exception as e: except Exception as e:
print(e) log.exception(f"Error generating PDF: {e}")
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))

View file

@ -1,10 +1,12 @@
import os import os
import shutil import shutil
import json import json
import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import BinaryIO, Tuple from typing import BinaryIO, Tuple
import boto3 import boto3
from botocore.config import Config
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from open_webui.config import ( from open_webui.config import (
S3_ACCESS_KEY_ID, S3_ACCESS_KEY_ID,
@ -13,14 +15,27 @@ from open_webui.config import (
S3_KEY_PREFIX, S3_KEY_PREFIX,
S3_REGION_NAME, S3_REGION_NAME,
S3_SECRET_ACCESS_KEY, S3_SECRET_ACCESS_KEY,
S3_USE_ACCELERATE_ENDPOINT,
S3_ADDRESSING_STYLE,
GCS_BUCKET_NAME, GCS_BUCKET_NAME,
GOOGLE_APPLICATION_CREDENTIALS_JSON, GOOGLE_APPLICATION_CREDENTIALS_JSON,
AZURE_STORAGE_ENDPOINT,
AZURE_STORAGE_CONTAINER_NAME,
AZURE_STORAGE_KEY,
STORAGE_PROVIDER, STORAGE_PROVIDER,
UPLOAD_DIR, UPLOAD_DIR,
) )
from google.cloud import storage from google.cloud import storage
from google.cloud.exceptions import GoogleCloudError, NotFound from google.cloud.exceptions import GoogleCloudError, NotFound
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient
from azure.core.exceptions import ResourceNotFoundError
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
class StorageProvider(ABC): class StorageProvider(ABC):
@ -65,7 +80,7 @@ class LocalStorageProvider(StorageProvider):
if os.path.isfile(file_path): if os.path.isfile(file_path):
os.remove(file_path) os.remove(file_path)
else: else:
print(f"File {file_path} not found in local storage.") log.warning(f"File {file_path} not found in local storage.")
@staticmethod @staticmethod
def delete_all_files() -> None: def delete_all_files() -> None:
@ -79,9 +94,9 @@ class LocalStorageProvider(StorageProvider):
elif os.path.isdir(file_path): elif os.path.isdir(file_path):
shutil.rmtree(file_path) # Remove the directory shutil.rmtree(file_path) # Remove the directory
except Exception as e: except Exception as e:
print(f"Failed to delete {file_path}. Reason: {e}") log.exception(f"Failed to delete {file_path}. Reason: {e}")
else: else:
print(f"Directory {UPLOAD_DIR} not found in local storage.") log.warning(f"Directory {UPLOAD_DIR} not found in local storage.")
class S3StorageProvider(StorageProvider): class S3StorageProvider(StorageProvider):
@ -92,6 +107,12 @@ class S3StorageProvider(StorageProvider):
endpoint_url=S3_ENDPOINT_URL, endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY_ID, aws_access_key_id=S3_ACCESS_KEY_ID,
aws_secret_access_key=S3_SECRET_ACCESS_KEY, aws_secret_access_key=S3_SECRET_ACCESS_KEY,
config=Config(
s3={
"use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT,
"addressing_style": S3_ADDRESSING_STYLE,
},
),
) )
self.bucket_name = S3_BUCKET_NAME self.bucket_name = S3_BUCKET_NAME
self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else "" self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else ""
@ -221,6 +242,74 @@ class GCSStorageProvider(StorageProvider):
LocalStorageProvider.delete_all_files() LocalStorageProvider.delete_all_files()
class AzureStorageProvider(StorageProvider):
def __init__(self):
self.endpoint = AZURE_STORAGE_ENDPOINT
self.container_name = AZURE_STORAGE_CONTAINER_NAME
storage_key = AZURE_STORAGE_KEY
if storage_key:
# Configure using the Azure Storage Account Endpoint and Key
self.blob_service_client = BlobServiceClient(
account_url=self.endpoint, credential=storage_key
)
else:
# Configure using the Azure Storage Account Endpoint and DefaultAzureCredential
# If the key is not configured, then the DefaultAzureCredential will be used to support Managed Identity authentication
self.blob_service_client = BlobServiceClient(
account_url=self.endpoint, credential=DefaultAzureCredential()
)
self.container_client = self.blob_service_client.get_container_client(
self.container_name
)
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to Azure Blob Storage."""
contents, file_path = LocalStorageProvider.upload_file(file, filename)
try:
blob_client = self.container_client.get_blob_client(filename)
blob_client.upload_blob(contents, overwrite=True)
return contents, f"{self.endpoint}/{self.container_name}/{filename}"
except Exception as e:
raise RuntimeError(f"Error uploading file to Azure Blob Storage: {e}")
def get_file(self, file_path: str) -> str:
"""Handles downloading of the file from Azure Blob Storage."""
try:
filename = file_path.split("/")[-1]
local_file_path = f"{UPLOAD_DIR}/{filename}"
blob_client = self.container_client.get_blob_client(filename)
with open(local_file_path, "wb") as download_file:
download_file.write(blob_client.download_blob().readall())
return local_file_path
except ResourceNotFoundError as e:
raise RuntimeError(f"Error downloading file from Azure Blob Storage: {e}")
def delete_file(self, file_path: str) -> None:
"""Handles deletion of the file from Azure Blob Storage."""
try:
filename = file_path.split("/")[-1]
blob_client = self.container_client.get_blob_client(filename)
blob_client.delete_blob()
except ResourceNotFoundError as e:
raise RuntimeError(f"Error deleting file from Azure Blob Storage: {e}")
# Always delete from local storage
LocalStorageProvider.delete_file(file_path)
def delete_all_files(self) -> None:
"""Handles deletion of all files from Azure Blob Storage."""
try:
blobs = self.container_client.list_blobs()
for blob in blobs:
self.container_client.delete_blob(blob.name)
except Exception as e:
raise RuntimeError(f"Error deleting all files from Azure Blob Storage: {e}")
# Always delete from local storage
LocalStorageProvider.delete_all_files()
def get_storage_provider(storage_provider: str): def get_storage_provider(storage_provider: str):
if storage_provider == "local": if storage_provider == "local":
Storage = LocalStorageProvider() Storage = LocalStorageProvider()
@ -228,6 +317,8 @@ def get_storage_provider(storage_provider: str):
Storage = S3StorageProvider() Storage = S3StorageProvider()
elif storage_provider == "gcs": elif storage_provider == "gcs":
Storage = GCSStorageProvider() Storage = GCSStorageProvider()
elif storage_provider == "azure":
Storage = AzureStorageProvider()
else: else:
raise RuntimeError(f"Unsupported storage provider: {storage_provider}") raise RuntimeError(f"Unsupported storage provider: {storage_provider}")
return Storage return Storage

View file

@ -7,6 +7,8 @@ from moto import mock_aws
from open_webui.storage import provider from open_webui.storage import provider
from gcp_storage_emulator.server import create_server from gcp_storage_emulator.server import create_server
from google.cloud import storage from google.cloud import storage
from azure.storage.blob import BlobServiceClient, ContainerClient, BlobClient
from unittest.mock import MagicMock
def mock_upload_dir(monkeypatch, tmp_path): def mock_upload_dir(monkeypatch, tmp_path):
@ -22,6 +24,7 @@ def test_imports():
provider.LocalStorageProvider provider.LocalStorageProvider
provider.S3StorageProvider provider.S3StorageProvider
provider.GCSStorageProvider provider.GCSStorageProvider
provider.AzureStorageProvider
provider.Storage provider.Storage
@ -32,6 +35,8 @@ def test_get_storage_provider():
assert isinstance(Storage, provider.S3StorageProvider) assert isinstance(Storage, provider.S3StorageProvider)
Storage = provider.get_storage_provider("gcs") Storage = provider.get_storage_provider("gcs")
assert isinstance(Storage, provider.GCSStorageProvider) assert isinstance(Storage, provider.GCSStorageProvider)
Storage = provider.get_storage_provider("azure")
assert isinstance(Storage, provider.AzureStorageProvider)
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
provider.get_storage_provider("invalid") provider.get_storage_provider("invalid")
@ -48,6 +53,7 @@ def test_class_instantiation():
provider.LocalStorageProvider() provider.LocalStorageProvider()
provider.S3StorageProvider() provider.S3StorageProvider()
provider.GCSStorageProvider() provider.GCSStorageProvider()
provider.AzureStorageProvider()
class TestLocalStorageProvider: class TestLocalStorageProvider:
@ -272,3 +278,147 @@ class TestGCSStorageProvider:
assert not (upload_dir / self.filename_extra).exists() assert not (upload_dir / self.filename_extra).exists()
assert self.Storage.bucket.get_blob(self.filename) == None assert self.Storage.bucket.get_blob(self.filename) == None
assert self.Storage.bucket.get_blob(self.filename_extra) == None assert self.Storage.bucket.get_blob(self.filename_extra) == None
class TestAzureStorageProvider:
def __init__(self):
super().__init__()
@pytest.fixture(scope="class")
def setup_storage(self, monkeypatch):
# Create mock Blob Service Client and related clients
mock_blob_service_client = MagicMock()
mock_container_client = MagicMock()
mock_blob_client = MagicMock()
# Set up return values for the mock
mock_blob_service_client.get_container_client.return_value = (
mock_container_client
)
mock_container_client.get_blob_client.return_value = mock_blob_client
# Monkeypatch the Azure classes to return our mocks
monkeypatch.setattr(
azure.storage.blob,
"BlobServiceClient",
lambda *args, **kwargs: mock_blob_service_client,
)
monkeypatch.setattr(
azure.storage.blob,
"ContainerClient",
lambda *args, **kwargs: mock_container_client,
)
monkeypatch.setattr(
azure.storage.blob, "BlobClient", lambda *args, **kwargs: mock_blob_client
)
self.Storage = provider.AzureStorageProvider()
self.Storage.endpoint = "https://myaccount.blob.core.windows.net"
self.Storage.container_name = "my-container"
self.file_content = b"test content"
self.filename = "test.txt"
self.filename_extra = "test_extra.txt"
self.file_bytesio_empty = io.BytesIO()
# Apply mocks to the Storage instance
self.Storage.blob_service_client = mock_blob_service_client
self.Storage.container_client = mock_container_client
def test_upload_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# Simulate an error when container does not exist
self.Storage.container_client.get_blob_client.side_effect = Exception(
"Container does not exist"
)
with pytest.raises(Exception):
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
# Reset side effect and create container
self.Storage.container_client.get_blob_client.side_effect = None
self.Storage.create_container()
contents, azure_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
# Assertions
self.Storage.container_client.get_blob_client.assert_called_with(self.filename)
self.Storage.container_client.get_blob_client().upload_blob.assert_called_once_with(
self.file_content, overwrite=True
)
assert contents == self.file_content
assert (
azure_file_path
== f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}"
)
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
with pytest.raises(ValueError):
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
def test_get_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
self.Storage.create_container()
# Mock upload behavior
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
# Mock blob download behavior
self.Storage.container_client.get_blob_client().download_blob().readall.return_value = (
self.file_content
)
file_url = f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}"
file_path = self.Storage.get_file(file_url)
assert file_path == str(upload_dir / self.filename)
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
def test_delete_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
self.Storage.create_container()
# Mock file upload
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
# Mock deletion
self.Storage.container_client.get_blob_client().delete_blob.return_value = None
file_url = f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}"
self.Storage.delete_file(file_url)
self.Storage.container_client.get_blob_client().delete_blob.assert_called_once()
assert not (upload_dir / self.filename).exists()
def test_delete_all_files(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
self.Storage.create_container()
# Mock file uploads
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename_extra)
# Mock listing and deletion behavior
self.Storage.container_client.list_blobs.return_value = [
{"name": self.filename},
{"name": self.filename_extra},
]
self.Storage.container_client.get_blob_client().delete_blob.return_value = None
self.Storage.delete_all_files()
self.Storage.container_client.list_blobs.assert_called_once()
self.Storage.container_client.get_blob_client().delete_blob.assert_any_call()
assert not (upload_dir / self.filename).exists()
assert not (upload_dir / self.filename_extra).exists()
def test_get_file_not_found(self, monkeypatch):
self.Storage.create_container()
file_url = f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}"
# Mock behavior to raise an error for missing blobs
self.Storage.container_client.get_blob_client().download_blob.side_effect = (
Exception("Blob not found")
)
with pytest.raises(Exception, match="Blob not found"):
self.Storage.get_file(file_url)

View file

@ -0,0 +1,249 @@
from contextlib import asynccontextmanager
from dataclasses import asdict, dataclass
from enum import Enum
import re
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Dict,
MutableMapping,
Optional,
cast,
)
import uuid
from asgiref.typing import (
ASGI3Application,
ASGIReceiveCallable,
ASGIReceiveEvent,
ASGISendCallable,
ASGISendEvent,
Scope as ASGIScope,
)
from loguru import logger
from starlette.requests import Request
from open_webui.env import AUDIT_LOG_LEVEL, MAX_BODY_LOG_SIZE
from open_webui.utils.auth import get_current_user, get_http_authorization_cred
from open_webui.models.users import UserModel
if TYPE_CHECKING:
from loguru import Logger
@dataclass(frozen=True)
class AuditLogEntry:
# `Metadata` audit level properties
id: str
user: dict[str, Any]
audit_level: str
verb: str
request_uri: str
user_agent: Optional[str] = None
source_ip: Optional[str] = None
# `Request` audit level properties
request_object: Any = None
# `Request Response` level
response_object: Any = None
response_status_code: Optional[int] = None
class AuditLevel(str, Enum):
NONE = "NONE"
METADATA = "METADATA"
REQUEST = "REQUEST"
REQUEST_RESPONSE = "REQUEST_RESPONSE"
class AuditLogger:
"""
A helper class that encapsulates audit logging functionality. It uses Logurus logger with an auditable binding to ensure that audit log entries are filtered correctly.
Parameters:
logger (Logger): An instance of Logurus logger.
"""
def __init__(self, logger: "Logger"):
self.logger = logger.bind(auditable=True)
def write(
self,
audit_entry: AuditLogEntry,
*,
log_level: str = "INFO",
extra: Optional[dict] = None,
):
entry = asdict(audit_entry)
if extra:
entry["extra"] = extra
self.logger.log(
log_level,
"",
**entry,
)
class AuditContext:
"""
Captures and aggregates the HTTP request and response bodies during the processing of a request. It ensures that only a configurable maximum amount of data is stored to prevent excessive memory usage.
Attributes:
request_body (bytearray): Accumulated request payload.
response_body (bytearray): Accumulated response payload.
max_body_size (int): Maximum number of bytes to capture.
metadata (Dict[str, Any]): A dictionary to store additional audit metadata (user, http verb, user agent, etc.).
"""
def __init__(self, max_body_size: int = MAX_BODY_LOG_SIZE):
self.request_body = bytearray()
self.response_body = bytearray()
self.max_body_size = max_body_size
self.metadata: Dict[str, Any] = {}
def add_request_chunk(self, chunk: bytes):
if len(self.request_body) < self.max_body_size:
self.request_body.extend(
chunk[: self.max_body_size - len(self.request_body)]
)
def add_response_chunk(self, chunk: bytes):
if len(self.response_body) < self.max_body_size:
self.response_body.extend(
chunk[: self.max_body_size - len(self.response_body)]
)
class AuditLoggingMiddleware:
"""
ASGI middleware that intercepts HTTP requests and responses to perform audit logging. It captures request/response bodies (depending on audit level), headers, HTTP methods, and user information, then logs a structured audit entry at the end of the request cycle.
"""
AUDITED_METHODS = {"PUT", "PATCH", "DELETE", "POST"}
def __init__(
self,
app: ASGI3Application,
*,
excluded_paths: Optional[list[str]] = None,
max_body_size: int = MAX_BODY_LOG_SIZE,
audit_level: AuditLevel = AuditLevel.NONE,
) -> None:
self.app = app
self.audit_logger = AuditLogger(logger)
self.excluded_paths = excluded_paths or []
self.max_body_size = max_body_size
self.audit_level = audit_level
async def __call__(
self,
scope: ASGIScope,
receive: ASGIReceiveCallable,
send: ASGISendCallable,
) -> None:
if scope["type"] != "http":
return await self.app(scope, receive, send)
request = Request(scope=cast(MutableMapping, scope))
if self._should_skip_auditing(request):
return await self.app(scope, receive, send)
async with self._audit_context(request) as context:
async def send_wrapper(message: ASGISendEvent) -> None:
if self.audit_level == AuditLevel.REQUEST_RESPONSE:
await self._capture_response(message, context)
await send(message)
original_receive = receive
async def receive_wrapper() -> ASGIReceiveEvent:
nonlocal original_receive
message = await original_receive()
if self.audit_level in (
AuditLevel.REQUEST,
AuditLevel.REQUEST_RESPONSE,
):
await self._capture_request(message, context)
return message
await self.app(scope, receive_wrapper, send_wrapper)
@asynccontextmanager
async def _audit_context(
self, request: Request
) -> AsyncGenerator[AuditContext, None]:
"""
async context manager that ensures that an audit log entry is recorded after the request is processed.
"""
context = AuditContext()
try:
yield context
finally:
await self._log_audit_entry(request, context)
async def _get_authenticated_user(self, request: Request) -> UserModel:
auth_header = request.headers.get("Authorization")
assert auth_header
user = get_current_user(request, None, get_http_authorization_cred(auth_header))
return user
def _should_skip_auditing(self, request: Request) -> bool:
if (
request.method not in {"POST", "PUT", "PATCH", "DELETE"}
or AUDIT_LOG_LEVEL == "NONE"
or not request.headers.get("authorization")
):
return True
# match either /api/<resource>/...(for the endpoint /api/chat case) or /api/v1/<resource>/...
pattern = re.compile(
r"^/api(?:/v1)?/(" + "|".join(self.excluded_paths) + r")\b"
)
if pattern.match(request.url.path):
return True
return False
async def _capture_request(self, message: ASGIReceiveEvent, context: AuditContext):
if message["type"] == "http.request":
body = message.get("body", b"")
context.add_request_chunk(body)
async def _capture_response(self, message: ASGISendEvent, context: AuditContext):
if message["type"] == "http.response.start":
context.metadata["response_status_code"] = message["status"]
elif message["type"] == "http.response.body":
body = message.get("body", b"")
context.add_response_chunk(body)
async def _log_audit_entry(self, request: Request, context: AuditContext):
try:
user = await self._get_authenticated_user(request)
entry = AuditLogEntry(
id=str(uuid.uuid4()),
user=user.model_dump(include={"id", "name", "email", "role"}),
audit_level=self.audit_level.value,
verb=request.method,
request_uri=str(request.url),
response_status_code=context.metadata.get("response_status_code", None),
source_ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
request_object=context.request_body.decode("utf-8", errors="replace"),
response_object=context.response_body.decode("utf-8", errors="replace"),
)
self.audit_logger.write(entry)
except Exception as e:
logger.error(f"Failed to log audit entry: {str(e)}")

View file

@ -5,6 +5,7 @@ import base64
import hmac import hmac
import hashlib import hashlib
import requests import requests
import os
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@ -13,15 +14,22 @@ from typing import Optional, Union, List, Dict
from open_webui.models.users import Users from open_webui.models.users import Users
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.config import override_static from open_webui.env import (
from open_webui.env import WEBUI_SECRET_KEY, TRUSTED_SIGNATURE_KEY WEBUI_SECRET_KEY,
TRUSTED_SIGNATURE_KEY,
STATIC_DIR,
SRC_LOG_LEVELS,
)
from fastapi import Depends, HTTPException, Request, Response, status from fastapi import BackgroundTasks, Depends, HTTPException, Request, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from passlib.context import CryptContext from passlib.context import CryptContext
logging.getLogger("passlib").setLevel(logging.ERROR) logging.getLogger("passlib").setLevel(logging.ERROR)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["OAUTH"])
SESSION_SECRET = WEBUI_SECRET_KEY SESSION_SECRET = WEBUI_SECRET_KEY
ALGORITHM = "HS256" ALGORITHM = "HS256"
@ -47,6 +55,19 @@ def verify_signature(payload: str, signature: str) -> bool:
return False return False
def override_static(path: str, content: str):
# Ensure path is safe
if "/" in path or ".." in path:
log.error(f"Invalid path: {path}")
return
file_path = os.path.join(STATIC_DIR, path)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb") as f:
f.write(base64.b64decode(content)) # Convert Base64 back to raw binary
def get_license_data(app, key): def get_license_data(app, key):
if key: if key:
try: try:
@ -69,11 +90,11 @@ def get_license_data(app, key):
return True return True
else: else:
print( log.error(
f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}" f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}"
) )
except Exception as ex: except Exception as ex:
print(f"License: Uncaught Exception: {ex}") log.exception(f"License: Uncaught Exception: {ex}")
return False return False
@ -129,6 +150,7 @@ def get_http_authorization_cred(auth_header: str):
def get_current_user( def get_current_user(
request: Request, request: Request,
background_tasks: BackgroundTasks,
auth_token: HTTPAuthorizationCredentials = Depends(bearer_security), auth_token: HTTPAuthorizationCredentials = Depends(bearer_security),
): ):
token = None token = None
@ -181,7 +203,10 @@ def get_current_user(
detail=ERROR_MESSAGES.INVALID_TOKEN, detail=ERROR_MESSAGES.INVALID_TOKEN,
) )
else: else:
Users.update_user_last_active_by_id(user.id) # Refresh the user's last active timestamp asynchronously
# to prevent blocking the request
if background_tasks:
background_tasks.add_task(Users.update_user_last_active_by_id, user.id)
return user return user
else: else:
raise HTTPException( raise HTTPException(

View file

@ -66,7 +66,7 @@ async def generate_direct_chat_completion(
user: Any, user: Any,
models: dict, models: dict,
): ):
print("generate_direct_chat_completion") log.info("generate_direct_chat_completion")
metadata = form_data.pop("metadata", {}) metadata = form_data.pop("metadata", {})
@ -103,7 +103,7 @@ async def generate_direct_chat_completion(
} }
) )
print("res", res) log.info(f"res: {res}")
if res.get("status", False): if res.get("status", False):
# Define a generator to stream responses # Define a generator to stream responses
@ -200,7 +200,7 @@ async def generate_chat_completion(
except Exception as e: except Exception as e:
raise e raise e
if model["owned_by"] == "arena": if model.get("owned_by") == "arena":
model_ids = model.get("info", {}).get("meta", {}).get("model_ids") model_ids = model.get("info", {}).get("meta", {}).get("model_ids")
filter_mode = model.get("info", {}).get("meta", {}).get("filter_mode") filter_mode = model.get("info", {}).get("meta", {}).get("filter_mode")
if model_ids and filter_mode == "exclude": if model_ids and filter_mode == "exclude":
@ -253,7 +253,7 @@ async def generate_chat_completion(
return await generate_function_chat_completion( return await generate_function_chat_completion(
request, form_data, user=user, models=models request, form_data, user=user, models=models
) )
if model["owned_by"] == "ollama": if model.get("owned_by") == "ollama":
# Using /ollama/api/chat endpoint # Using /ollama/api/chat endpoint
form_data = convert_payload_openai_to_ollama(form_data) form_data = convert_payload_openai_to_ollama(form_data)
response = await generate_ollama_chat_completion( response = await generate_ollama_chat_completion(
@ -285,7 +285,7 @@ chat_completion = generate_chat_completion
async def chat_completed(request: Request, form_data: dict, user: Any): async def chat_completed(request: Request, form_data: dict, user: Any):
if not request.app.state.MODELS: if not request.app.state.MODELS:
await get_all_models(request) await get_all_models(request, user=user)
if getattr(request.state, "direct", False) and hasattr(request.state, "model"): if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = { models = {
@ -351,7 +351,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
raise Exception(f"Action not found: {action_id}") raise Exception(f"Action not found: {action_id}")
if not request.app.state.MODELS: if not request.app.state.MODELS:
await get_all_models(request) await get_all_models(request, user=user)
if getattr(request.state, "direct", False) and hasattr(request.state, "model"): if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = { models = {
@ -432,7 +432,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
) )
) )
except Exception as e: except Exception as e:
print(e) log.exception(f"Failed to get user values: {e}")
params = {**params, "__user__": __user__} params = {**params, "__user__": __user__}

View file

@ -1,6 +1,12 @@
import inspect import inspect
import logging
from open_webui.utils.plugin import load_function_module_by_id from open_webui.utils.plugin import load_function_module_by_id
from open_webui.models.functions import Functions from open_webui.models.functions import Functions
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
def get_sorted_filter_ids(model): def get_sorted_filter_ids(model):
@ -61,7 +67,12 @@ async def process_filter_functions(
try: try:
# Prepare parameters # Prepare parameters
sig = inspect.signature(handler) sig = inspect.signature(handler)
params = {"body": form_data} | {
params = {"body": form_data}
if filter_type == "stream":
params = {"event": form_data}
params = params | {
k: v k: v
for k, v in { for k, v in {
**extra_params, **extra_params,
@ -80,7 +91,7 @@ async def process_filter_functions(
) )
) )
except Exception as e: except Exception as e:
print(e) log.exception(f"Failed to get user values: {e}")
# Execute handler # Execute handler
if inspect.iscoroutinefunction(handler): if inspect.iscoroutinefunction(handler):
@ -89,7 +100,7 @@ async def process_filter_functions(
form_data = handler(**params) form_data = handler(**params)
except Exception as e: except Exception as e:
print(f"Error in {filter_type} handler {filter_id}: {e}") log.exception(f"Error in {filter_type} handler {filter_id}: {e}")
raise e raise e
# Handle file cleanup for inlet # Handle file cleanup for inlet

View file

@ -0,0 +1,140 @@
import json
import logging
import sys
from typing import TYPE_CHECKING
from loguru import logger
from open_webui.env import (
AUDIT_LOG_FILE_ROTATION_SIZE,
AUDIT_LOG_LEVEL,
AUDIT_LOGS_FILE_PATH,
GLOBAL_LOG_LEVEL,
)
if TYPE_CHECKING:
from loguru import Record
def stdout_format(record: "Record") -> str:
"""
Generates a formatted string for log records that are output to the console. This format includes a timestamp, log level, source location (module, function, and line), the log message, and any extra data (serialized as JSON).
Parameters:
record (Record): A Loguru record that contains logging details including time, level, name, function, line, message, and any extra context.
Returns:
str: A formatted log string intended for stdout.
"""
record["extra"]["extra_json"] = json.dumps(record["extra"])
return (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level> - {extra[extra_json]}"
"\n{exception}"
)
class InterceptHandler(logging.Handler):
"""
Intercepts log records from Python's standard logging module
and redirects them to Loguru's logger.
"""
def emit(self, record):
"""
Called by the standard logging module for each log event.
It transforms the standard `LogRecord` into a format compatible with Loguru
and passes it to Loguru's logger.
"""
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame, depth = sys._getframe(6), 6
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
def file_format(record: "Record"):
"""
Formats audit log records into a structured JSON string for file output.
Parameters:
record (Record): A Loguru record containing extra audit data.
Returns:
str: A JSON-formatted string representing the audit data.
"""
audit_data = {
"id": record["extra"].get("id", ""),
"timestamp": int(record["time"].timestamp()),
"user": record["extra"].get("user", dict()),
"audit_level": record["extra"].get("audit_level", ""),
"verb": record["extra"].get("verb", ""),
"request_uri": record["extra"].get("request_uri", ""),
"response_status_code": record["extra"].get("response_status_code", 0),
"source_ip": record["extra"].get("source_ip", ""),
"user_agent": record["extra"].get("user_agent", ""),
"request_object": record["extra"].get("request_object", b""),
"response_object": record["extra"].get("response_object", b""),
"extra": record["extra"].get("extra", {}),
}
record["extra"]["file_extra"] = json.dumps(audit_data, default=str)
return "{extra[file_extra]}\n"
def start_logger():
"""
Initializes and configures Loguru's logger with distinct handlers:
A console (stdout) handler for general log messages (excluding those marked as auditable).
An optional file handler for audit logs if audit logging is enabled.
Additionally, this function reconfigures Pythons standard logging to route through Loguru and adjusts logging levels for Uvicorn.
Parameters:
enable_audit_logging (bool): Determines whether audit-specific log entries should be recorded to file.
"""
logger.remove()
logger.add(
sys.stdout,
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=lambda record: "auditable" not in record["extra"],
)
if AUDIT_LOG_LEVEL != "NONE":
try:
logger.add(
AUDIT_LOGS_FILE_PATH,
level="INFO",
rotation=AUDIT_LOG_FILE_ROTATION_SIZE,
compression="zip",
format=file_format,
filter=lambda record: record["extra"].get("auditable") is True,
)
except Exception as e:
logger.error(f"Failed to initialize audit log file handler: {str(e)}")
logging.basicConfig(
handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True
)
for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]:
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
uvicorn_logger.handlers = []
for uvicorn_logger_name in ["uvicorn.access"]:
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
uvicorn_logger.handlers = [InterceptHandler()]
logger.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}")

View file

@ -322,8 +322,9 @@ async def chat_web_search_handler(
) )
return form_data return form_data
searchQuery = queries[0] all_results = []
for searchQuery in queries:
await event_emitter( await event_emitter(
{ {
"type": "status", "type": "status",
@ -337,7 +338,6 @@ async def chat_web_search_handler(
) )
try: try:
results = await process_web_search( results = await process_web_search(
request, request,
SearchForm( SearchForm(
@ -345,46 +345,33 @@ async def chat_web_search_handler(
"query": searchQuery, "query": searchQuery,
} }
), ),
user, user=user,
) )
if results: if results:
await event_emitter( all_results.append(results)
{
"type": "status",
"data": {
"action": "web_search",
"description": "Searched {{count}} sites",
"query": searchQuery,
"urls": results["filenames"],
"done": True,
},
}
)
files = form_data.get("files", []) files = form_data.get("files", [])
if results.get("collection_name"):
files.append( files.append(
{ {
"collection_name": results["collection_name"], "collection_name": results["collection_name"],
"name": searchQuery, "name": searchQuery,
"type": "web_search_results", "type": "web_search",
"urls": results["filenames"], "urls": results["filenames"],
} }
) )
form_data["files"] = files elif results.get("docs"):
else: files.append(
await event_emitter(
{ {
"type": "status", "docs": results.get("docs", []),
"data": { "name": searchQuery,
"action": "web_search", "type": "web_search",
"description": "No search results found", "urls": results["filenames"],
"query": searchQuery,
"done": True,
"error": True,
},
} }
) )
form_data["files"] = files
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
await event_emitter( await event_emitter(
@ -400,6 +387,36 @@ async def chat_web_search_handler(
} }
) )
if all_results:
urls = []
for results in all_results:
if "filenames" in results:
urls.extend(results["filenames"])
await event_emitter(
{
"type": "status",
"data": {
"action": "web_search",
"description": "Searched {{count}} sites",
"urls": urls,
"done": True,
},
}
)
else:
await event_emitter(
{
"type": "status",
"data": {
"action": "web_search",
"description": "No search results found",
"done": True,
"error": True,
},
}
)
return form_data return form_data
@ -503,6 +520,7 @@ async def chat_completion_files_handler(
sources = [] sources = []
if files := body.get("metadata", {}).get("files", None): if files := body.get("metadata", {}).get("files", None):
queries = []
try: try:
queries_response = await generate_queries( queries_response = await generate_queries(
request, request,
@ -528,8 +546,8 @@ async def chat_completion_files_handler(
queries_response = {"queries": [queries_response]} queries_response = {"queries": [queries_response]}
queries = queries_response.get("queries", []) queries = queries_response.get("queries", [])
except Exception as e: except:
queries = [] pass
if len(queries) == 0: if len(queries) == 0:
queries = [get_last_user_message(body["messages"])] queries = [get_last_user_message(body["messages"])]
@ -541,6 +559,7 @@ async def chat_completion_files_handler(
sources = await loop.run_in_executor( sources = await loop.run_in_executor(
executor, executor,
lambda: get_sources_from_files( lambda: get_sources_from_files(
request=request,
files=files, files=files,
queries=queries, queries=queries,
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
@ -550,9 +569,9 @@ async def chat_completion_files_handler(
reranking_function=request.app.state.rf, reranking_function=request.app.state.rf,
r=request.app.state.config.RELEVANCE_THRESHOLD, r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
full_context=request.app.state.config.RAG_FULL_CONTEXT,
), ),
) )
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
@ -728,6 +747,7 @@ async def process_chat_payload(request, form_data, metadata, user, model):
tool_ids = form_data.pop("tool_ids", None) tool_ids = form_data.pop("tool_ids", None)
files = form_data.pop("files", None) files = form_data.pop("files", None)
# Remove files duplicates # Remove files duplicates
if files: if files:
files = list({json.dumps(f, sort_keys=True): f for f in files}.values()) files = list({json.dumps(f, sort_keys=True): f for f in files}.values())
@ -785,8 +805,6 @@ async def process_chat_payload(request, form_data, metadata, user, model):
if len(sources) > 0: if len(sources) > 0:
context_string = "" context_string = ""
for source_idx, source in enumerate(sources): for source_idx, source in enumerate(sources):
source_id = source.get("source", {}).get("name", "")
if "document" in source: if "document" in source:
for doc_idx, doc_context in enumerate(source["document"]): for doc_idx, doc_context in enumerate(source["document"]):
context_string += f"<source><source_id>{source_idx}</source_id><source_context>{doc_context}</source_context></source>\n" context_string += f"<source><source_id>{source_idx}</source_id><source_context>{doc_context}</source_context></source>\n"
@ -806,7 +824,7 @@ async def process_chat_payload(request, form_data, metadata, user, model):
# Workaround for Ollama 2.0+ system prompt issue # Workaround for Ollama 2.0+ system prompt issue
# TODO: replace with add_or_update_system_message # TODO: replace with add_or_update_system_message
if model["owned_by"] == "ollama": if model.get("owned_by") == "ollama":
form_data["messages"] = prepend_to_first_user_message_content( form_data["messages"] = prepend_to_first_user_message_content(
rag_template( rag_template(
request.app.state.config.RAG_TEMPLATE, context_string, prompt request.app.state.config.RAG_TEMPLATE, context_string, prompt
@ -1038,6 +1056,21 @@ async def process_chat_response(
): ):
return response return response
extra_params = {
"__event_emitter__": event_emitter,
"__event_call__": event_caller,
"__user__": {
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
},
"__metadata__": metadata,
"__request__": request,
"__model__": metadata.get("model"),
}
filter_ids = get_sorted_filter_ids(form_data.get("model"))
# Streaming response # Streaming response
if event_emitter and event_caller: if event_emitter and event_caller:
task_id = str(uuid4()) # Create a unique task ID. task_id = str(uuid4()) # Create a unique task ID.
@ -1117,12 +1150,12 @@ async def process_chat_response(
if reasoning_duration is not None: if reasoning_duration is not None:
if raw: if raw:
content = f'{content}\n<{block["tag"]}>{block["content"]}</{block["tag"]}>\n' content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["end_tag"]}>\n'
else: else:
content = f'{content}\n<details type="reasoning" done="true" duration="{reasoning_duration}">\n<summary>Thought for {reasoning_duration} seconds</summary>\n{reasoning_display_content}\n</details>\n' content = f'{content}\n<details type="reasoning" done="true" duration="{reasoning_duration}">\n<summary>Thought for {reasoning_duration} seconds</summary>\n{reasoning_display_content}\n</details>\n'
else: else:
if raw: if raw:
content = f'{content}\n<{block["tag"]}>{block["content"]}</{block["tag"]}>\n' content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["end_tag"]}>\n'
else: else:
content = f'{content}\n<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{reasoning_display_content}\n</details>\n' content = f'{content}\n<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{reasoning_display_content}\n</details>\n'
@ -1218,9 +1251,9 @@ async def process_chat_response(
return attributes return attributes
if content_blocks[-1]["type"] == "text": if content_blocks[-1]["type"] == "text":
for tag in tags: for start_tag, end_tag in tags:
# Match start tag e.g., <tag> or <tag attr="value"> # Match start tag e.g., <tag> or <tag attr="value">
start_tag_pattern = rf"<{tag}(\s.*?)?>" start_tag_pattern = rf"<{re.escape(start_tag)}(\s.*?)?>"
match = re.search(start_tag_pattern, content) match = re.search(start_tag_pattern, content)
if match: if match:
attr_content = ( attr_content = (
@ -1253,7 +1286,8 @@ async def process_chat_response(
content_blocks.append( content_blocks.append(
{ {
"type": content_type, "type": content_type,
"tag": tag, "start_tag": start_tag,
"end_tag": end_tag,
"attributes": attributes, "attributes": attributes,
"content": "", "content": "",
"started_at": time.time(), "started_at": time.time(),
@ -1265,9 +1299,10 @@ async def process_chat_response(
break break
elif content_blocks[-1]["type"] == content_type: elif content_blocks[-1]["type"] == content_type:
tag = content_blocks[-1]["tag"] start_tag = content_blocks[-1]["start_tag"]
end_tag = content_blocks[-1]["end_tag"]
# Match end tag e.g., </tag> # Match end tag e.g., </tag>
end_tag_pattern = rf"</{tag}>" end_tag_pattern = rf"<{re.escape(end_tag)}>"
# Check if the content has the end tag # Check if the content has the end tag
if re.search(end_tag_pattern, content): if re.search(end_tag_pattern, content):
@ -1275,7 +1310,7 @@ async def process_chat_response(
block_content = content_blocks[-1]["content"] block_content = content_blocks[-1]["content"]
# Strip start and end tags from the content # Strip start and end tags from the content
start_tag_pattern = rf"<{tag}(.*?)>" start_tag_pattern = rf"<{re.escape(start_tag)}(.*?)>"
block_content = re.sub( block_content = re.sub(
start_tag_pattern, "", block_content start_tag_pattern, "", block_content
).strip() ).strip()
@ -1340,7 +1375,7 @@ async def process_chat_response(
# Clean processed content # Clean processed content
content = re.sub( content = re.sub(
rf"<{tag}(.*?)>(.|\n)*?</{tag}>", rf"<{re.escape(start_tag)}(.*?)>(.|\n)*?<{re.escape(end_tag)}>",
"", "",
content, content,
flags=re.DOTALL, flags=re.DOTALL,
@ -1353,7 +1388,22 @@ async def process_chat_response(
) )
tool_calls = [] tool_calls = []
content = message.get("content", "") if message else ""
last_assistant_message = None
try:
if form_data["messages"][-1]["role"] == "assistant":
last_assistant_message = get_last_assistant_message(
form_data["messages"]
)
except Exception as e:
pass
content = (
message.get("content", "")
if message
else last_assistant_message if last_assistant_message else ""
)
content_blocks = [ content_blocks = [
{ {
"type": "text", "type": "text",
@ -1363,19 +1413,24 @@ async def process_chat_response(
# We might want to disable this by default # We might want to disable this by default
DETECT_REASONING = True DETECT_REASONING = True
DETECT_SOLUTION = True
DETECT_CODE_INTERPRETER = metadata.get("features", {}).get( DETECT_CODE_INTERPRETER = metadata.get("features", {}).get(
"code_interpreter", False "code_interpreter", False
) )
reasoning_tags = [ reasoning_tags = [
"think", ("think", "/think"),
"thinking", ("thinking", "/thinking"),
"reason", ("reason", "/reason"),
"reasoning", ("reasoning", "/reasoning"),
"thought", ("thought", "/thought"),
"Thought", ("Thought", "/Thought"),
("|begin_of_thought|", "|end_of_thought|"),
] ]
code_interpreter_tags = ["code_interpreter"]
code_interpreter_tags = [("code_interpreter", "/code_interpreter")]
solution_tags = [("|begin_of_solution|", "|end_of_solution|")]
try: try:
for event in events: for event in events:
@ -1419,6 +1474,15 @@ async def process_chat_response(
try: try:
data = json.loads(data) data = json.loads(data)
data, _ = await process_filter_functions(
request=request,
filter_ids=filter_ids,
filter_type="stream",
form_data=data,
extra_params=extra_params,
)
if data:
if "selected_model_id" in data: if "selected_model_id" in data:
model_id = data["selected_model_id"] model_id = data["selected_model_id"]
Chats.upsert_message_to_chat_by_id_and_message_id( Chats.upsert_message_to_chat_by_id_and_message_id(
@ -1431,6 +1495,16 @@ async def process_chat_response(
else: else:
choices = data.get("choices", []) choices = data.get("choices", [])
if not choices: if not choices:
usage = data.get("usage", {})
if usage:
await event_emitter(
{
"type": "chat:completion",
"data": {
"usage": usage,
},
}
)
continue continue
delta = choices[0].get("delta", {}) delta = choices[0].get("delta", {})
@ -1438,7 +1512,9 @@ async def process_chat_response(
if delta_tool_calls: if delta_tool_calls:
for delta_tool_call in delta_tool_calls: for delta_tool_call in delta_tool_calls:
tool_call_index = delta_tool_call.get("index") tool_call_index = delta_tool_call.get(
"index"
)
if tool_call_index is not None: if tool_call_index is not None:
if ( if (
@ -1452,14 +1528,18 @@ async def process_chat_response(
delta_name = delta_tool_call.get( delta_name = delta_tool_call.get(
"function", {} "function", {}
).get("name") ).get("name")
delta_arguments = delta_tool_call.get( delta_arguments = (
delta_tool_call.get(
"function", {} "function", {}
).get("arguments") ).get("arguments")
)
if delta_name: if delta_name:
response_tool_calls[ response_tool_calls[
tool_call_index tool_call_index
]["function"]["name"] += delta_name ]["function"][
"name"
] += delta_name
if delta_arguments: if delta_arguments:
response_tool_calls[ response_tool_calls[
@ -1508,6 +1588,16 @@ async def process_chat_response(
if end: if end:
break break
if DETECT_SOLUTION:
content, content_blocks, _ = (
tag_content_handler(
"solution",
solution_tags,
content,
content_blocks,
)
)
if ENABLE_REALTIME_CHAT_SAVE: if ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database # Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id( Chats.upsert_message_to_chat_by_id_and_message_id(
@ -1736,6 +1826,7 @@ async def process_chat_response(
== "password" == "password"
else None else None
), ),
request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT,
) )
else: else:
output = { output = {
@ -1829,7 +1920,10 @@ async def process_chat_response(
} }
) )
print(content_blocks, serialize_content_blocks(content_blocks)) log.info(f"content_blocks={content_blocks}")
log.info(
f"serialize_content_blocks={serialize_content_blocks(content_blocks)}"
)
try: try:
res = await generate_chat_completion( res = await generate_chat_completion(
@ -1900,7 +1994,7 @@ async def process_chat_response(
await background_tasks_handler() await background_tasks_handler()
except asyncio.CancelledError: except asyncio.CancelledError:
print("Task was cancelled!") log.warning("Task was cancelled!")
await event_emitter({"type": "task-cancelled"}) await event_emitter({"type": "task-cancelled"})
if not ENABLE_REALTIME_CHAT_SAVE: if not ENABLE_REALTIME_CHAT_SAVE:
@ -1921,16 +2015,33 @@ async def process_chat_response(
return {"status": True, "task_id": task_id} return {"status": True, "task_id": task_id}
else: else:
# Fallback to the original response # Fallback to the original response
async def stream_wrapper(original_generator, events): async def stream_wrapper(original_generator, events):
def wrap_item(item): def wrap_item(item):
return f"data: {item}\n\n" return f"data: {item}\n\n"
for event in events: for event in events:
event, _ = await process_filter_functions(
request=request,
filter_ids=filter_ids,
filter_type="stream",
form_data=event,
extra_params=extra_params,
)
if event:
yield wrap_item(json.dumps(event)) yield wrap_item(json.dumps(event))
async for data in original_generator: async for data in original_generator:
data, _ = await process_filter_functions(
request=request,
filter_ids=filter_ids,
filter_type="stream",
form_data=data,
extra_params=extra_params,
)
if data:
yield data yield data
return StreamingResponse( return StreamingResponse(

View file

@ -2,6 +2,7 @@ import hashlib
import re import re
import time import time
import uuid import uuid
import logging
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
@ -9,6 +10,10 @@ import json
import collections.abc import collections.abc
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
def deep_update(d, u): def deep_update(d, u):
@ -413,7 +418,7 @@ def parse_ollama_modelfile(model_text):
elif param_type is bool: elif param_type is bool:
value = value.lower() == "true" value = value.lower() == "true"
except Exception as e: except Exception as e:
print(e) log.exception(f"Failed to parse parameter {param}: {e}")
continue continue
data["params"][param] = value data["params"][param] = value

View file

@ -22,6 +22,7 @@ from open_webui.config import (
) )
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
from open_webui.models.users import UserModel
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
@ -29,17 +30,17 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"]) log.setLevel(SRC_LOG_LEVELS["MAIN"])
async def get_all_base_models(request: Request): async def get_all_base_models(request: Request, user: UserModel = None):
function_models = [] function_models = []
openai_models = [] openai_models = []
ollama_models = [] ollama_models = []
if request.app.state.config.ENABLE_OPENAI_API: if request.app.state.config.ENABLE_OPENAI_API:
openai_models = await openai.get_all_models(request) openai_models = await openai.get_all_models(request, user=user)
openai_models = openai_models["data"] openai_models = openai_models["data"]
if request.app.state.config.ENABLE_OLLAMA_API: if request.app.state.config.ENABLE_OLLAMA_API:
ollama_models = await ollama.get_all_models(request) ollama_models = await ollama.get_all_models(request, user=user)
ollama_models = [ ollama_models = [
{ {
"id": model["model"], "id": model["model"],
@ -58,8 +59,8 @@ async def get_all_base_models(request: Request):
return models return models
async def get_all_models(request): async def get_all_models(request, user: UserModel = None):
models = await get_all_base_models(request) models = await get_all_base_models(request, user=user)
# If there are no models, return an empty list # If there are no models, return an empty list
if len(models) == 0: if len(models) == 0:
@ -142,7 +143,7 @@ async def get_all_models(request):
custom_model.base_model_id == model["id"] custom_model.base_model_id == model["id"]
or custom_model.base_model_id == model["id"].split(":")[0] or custom_model.base_model_id == model["id"].split(":")[0]
): ):
owned_by = model["owned_by"] owned_by = model.get("owned_by", "unknown owner")
if "pipe" in model: if "pipe" in model:
pipe = model["pipe"] pipe = model["pipe"]
break break

View file

@ -140,7 +140,14 @@ class OAuthManager:
log.debug("Running OAUTH Group management") log.debug("Running OAUTH Group management")
oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
user_oauth_groups: list[str] = user_data.get(oauth_claim, list()) # Nested claim search for groups claim
if oauth_claim:
claim_data = user_data
nested_claims = oauth_claim.split(".")
for nested_claim in nested_claims:
claim_data = claim_data.get(nested_claim, {})
user_oauth_groups = claim_data if isinstance(claim_data, list) else []
user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id) user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id)
all_available_groups: list[GroupModel] = Groups.get_groups() all_available_groups: list[GroupModel] = Groups.get_groups()
@ -239,11 +246,46 @@ class OAuthManager:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
provider_sub = f"{provider}@{sub}" provider_sub = f"{provider}@{sub}"
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
email = user_data.get(email_claim, "").lower() email = user_data.get(email_claim, "")
# We currently mandate that email addresses are provided # We currently mandate that email addresses are provided
if not email: if not email:
# If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
if provider == "github":
try:
access_token = token.get("access_token")
headers = {"Authorization": f"Bearer {access_token}"}
async with aiohttp.ClientSession() as session:
async with session.get(
"https://api.github.com/user/emails", headers=headers
) as resp:
if resp.ok:
emails = await resp.json()
# use the primary email as the user's email
primary_email = next(
(e["email"] for e in emails if e.get("primary")),
None,
)
if primary_email:
email = primary_email
else:
log.warning(
"No primary email found in GitHub response"
)
raise HTTPException(
400, detail=ERROR_MESSAGES.INVALID_CRED
)
else:
log.warning("Failed to fetch GitHub email")
raise HTTPException(
400, detail=ERROR_MESSAGES.INVALID_CRED
)
except Exception as e:
log.warning(f"Error fetching GitHub email: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
else:
log.warning(f"OAuth callback failed, email is missing: {user_data}") log.warning(f"OAuth callback failed, email is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
email = email.lower()
if ( if (
"*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
and email.split("@")[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS and email.split("@")[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
@ -273,21 +315,10 @@ class OAuthManager:
if not user: if not user:
user_count = Users.get_num_users() user_count = Users.get_num_users()
if (
request.app.state.USER_COUNT
and user_count >= request.app.state.USER_COUNT
):
raise HTTPException(
403,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# If the user does not exist, check if signups are enabled # If the user does not exist, check if signups are enabled
if auth_manager_config.ENABLE_OAUTH_SIGNUP: if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists # Check if an existing user with the same email already exists
existing_user = Users.get_user_by_email( existing_user = Users.get_user_by_email(email)
user_data.get("email", "").lower()
)
if existing_user: if existing_user:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)

View file

@ -4,6 +4,7 @@ from open_webui.utils.misc import (
) )
from typing import Callable, Optional from typing import Callable, Optional
import json
# inplace function: form_data is modified # inplace function: form_data is modified
@ -67,38 +68,49 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict: def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
opts = [ # Convert OpenAI parameter names to Ollama parameter names if needed.
"temperature",
"top_p",
"seed",
"mirostat",
"mirostat_eta",
"mirostat_tau",
"num_ctx",
"num_batch",
"num_keep",
"repeat_last_n",
"tfs_z",
"top_k",
"min_p",
"use_mmap",
"use_mlock",
"num_thread",
"num_gpu",
]
mappings = {i: lambda x: x for i in opts}
form_data = apply_model_params_to_body(params, form_data, mappings)
name_differences = { name_differences = {
"max_tokens": "num_predict", "max_tokens": "num_predict",
"frequency_penalty": "repeat_penalty",
} }
for key, value in name_differences.items(): for key, value in name_differences.items():
if (param := params.get(key, None)) is not None: if (param := params.get(key, None)) is not None:
form_data[value] = param # Copy the parameter to new name then delete it, to prevent Ollama warning of invalid option provided
params[value] = params[key]
del params[key]
return form_data # See https://github.com/ollama/ollama/blob/main/docs/api.md#request-8
mappings = {
"temperature": float,
"top_p": float,
"seed": lambda x: x,
"mirostat": int,
"mirostat_eta": float,
"mirostat_tau": float,
"num_ctx": int,
"num_batch": int,
"num_keep": int,
"num_predict": int,
"repeat_last_n": int,
"top_k": int,
"min_p": float,
"typical_p": float,
"repeat_penalty": float,
"presence_penalty": float,
"frequency_penalty": float,
"penalize_newline": bool,
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
"numa": bool,
"num_gpu": int,
"main_gpu": int,
"low_vram": bool,
"vocab_only": bool,
"use_mmap": bool,
"use_mlock": bool,
"num_thread": int,
}
return apply_model_params_to_body(params, form_data, mappings)
def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]: def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]:
@ -109,11 +121,38 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]:
new_message = {"role": message["role"]} new_message = {"role": message["role"]}
content = message.get("content", []) content = message.get("content", [])
tool_calls = message.get("tool_calls", None)
tool_call_id = message.get("tool_call_id", None)
# Check if the content is a string (just a simple message) # Check if the content is a string (just a simple message)
if isinstance(content, str): if isinstance(content, str) and not tool_calls:
# If the content is a string, it's pure text # If the content is a string, it's pure text
new_message["content"] = content new_message["content"] = content
# If message is a tool call, add the tool call id to the message
if tool_call_id:
new_message["tool_call_id"] = tool_call_id
elif tool_calls:
# If tool calls are present, add them to the message
ollama_tool_calls = []
for tool_call in tool_calls:
ollama_tool_call = {
"index": tool_call.get("index", 0),
"id": tool_call.get("id", None),
"function": {
"name": tool_call.get("function", {}).get("name", ""),
"arguments": json.loads(
tool_call.get("function", {}).get("arguments", {})
),
},
}
ollama_tool_calls.append(ollama_tool_call)
new_message["tool_calls"] = ollama_tool_calls
# Put the content to empty string (Ollama requires an empty string for tool calls)
new_message["content"] = ""
else: else:
# Otherwise, assume the content is a list of dicts, e.g., text followed by an image URL # Otherwise, assume the content is a list of dicts, e.g., text followed by an image URL
content_text = "" content_text = ""
@ -174,33 +213,28 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
ollama_payload["format"] = openai_payload["format"] ollama_payload["format"] = openai_payload["format"]
# If there are advanced parameters in the payload, format them in Ollama's options field # If there are advanced parameters in the payload, format them in Ollama's options field
ollama_options = {}
if openai_payload.get("options"): if openai_payload.get("options"):
ollama_payload["options"] = openai_payload["options"] ollama_payload["options"] = openai_payload["options"]
ollama_options = openai_payload["options"] ollama_options = openai_payload["options"]
# Handle parameters which map directly # Re-Mapping OpenAI's `max_tokens` -> Ollama's `num_predict`
for param in ["temperature", "top_p", "seed"]: if "max_tokens" in ollama_options:
if param in openai_payload: ollama_options["num_predict"] = ollama_options["max_tokens"]
ollama_options[param] = openai_payload[param] del ollama_options[
"max_tokens"
] # To prevent Ollama warning of invalid option provided
# Mapping OpenAI's `max_tokens` -> Ollama's `num_predict` # Ollama lacks a "system" prompt option. It has to be provided as a direct parameter, so we copy it down.
if "max_completion_tokens" in openai_payload: if "system" in ollama_options:
ollama_options["num_predict"] = openai_payload["max_completion_tokens"] ollama_payload["system"] = ollama_options["system"]
elif "max_tokens" in openai_payload: del ollama_options[
ollama_options["num_predict"] = openai_payload["max_tokens"] "system"
] # To prevent Ollama warning of invalid option provided
# Handle frequency / presence_penalty, which needs renaming and checking # If there is the "stop" parameter in the openai_payload, remap it to the ollama_payload.options
if "frequency_penalty" in openai_payload: if "stop" in openai_payload:
ollama_options["repeat_penalty"] = openai_payload["frequency_penalty"] ollama_options = ollama_payload.get("options", {})
ollama_options["stop"] = openai_payload.get("stop")
if "presence_penalty" in openai_payload and "penalty" not in ollama_options:
# We are assuming presence penalty uses a similar concept in Ollama, which needs custom handling if exists.
ollama_options["new_topic_penalty"] = openai_payload["presence_penalty"]
# Add options to payload if any have been set
if ollama_options:
ollama_payload["options"] = ollama_options ollama_payload["options"] = ollama_options
if "metadata" in openai_payload: if "metadata" in openai_payload:

View file

@ -45,7 +45,7 @@ def extract_frontmatter(content):
frontmatter[key.strip()] = value.strip() frontmatter[key.strip()] = value.strip()
except Exception as e: except Exception as e:
print(f"An error occurred: {e}") log.exception(f"Failed to extract frontmatter: {e}")
return {} return {}
return frontmatter return frontmatter

View file

@ -24,17 +24,8 @@ def convert_ollama_tool_call_to_openai(tool_calls: dict) -> dict:
return openai_tool_calls return openai_tool_calls
def convert_response_ollama_to_openai(ollama_response: dict) -> dict: def convert_ollama_usage_to_openai(data: dict) -> dict:
model = ollama_response.get("model", "ollama") return {
message_content = ollama_response.get("message", {}).get("content", "")
tool_calls = ollama_response.get("message", {}).get("tool_calls", None)
openai_tool_calls = None
if tool_calls:
openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls)
data = ollama_response
usage = {
"response_token/s": ( "response_token/s": (
round( round(
( (
@ -66,14 +57,42 @@ def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
"total_duration": data.get("total_duration", 0), "total_duration": data.get("total_duration", 0),
"load_duration": data.get("load_duration", 0), "load_duration": data.get("load_duration", 0),
"prompt_eval_count": data.get("prompt_eval_count", 0), "prompt_eval_count": data.get("prompt_eval_count", 0),
"prompt_tokens": int(
data.get("prompt_eval_count", 0)
), # This is the OpenAI compatible key
"prompt_eval_duration": data.get("prompt_eval_duration", 0), "prompt_eval_duration": data.get("prompt_eval_duration", 0),
"eval_count": data.get("eval_count", 0), "eval_count": data.get("eval_count", 0),
"completion_tokens": int(
data.get("eval_count", 0)
), # This is the OpenAI compatible key
"eval_duration": data.get("eval_duration", 0), "eval_duration": data.get("eval_duration", 0),
"approximate_total": (lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s")( "approximate_total": (lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s")(
(data.get("total_duration", 0) or 0) // 1_000_000_000 (data.get("total_duration", 0) or 0) // 1_000_000_000
), ),
"total_tokens": int( # This is the OpenAI compatible key
data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
),
"completion_tokens_details": { # This is the OpenAI compatible key
"reasoning_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0,
},
} }
def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
model = ollama_response.get("model", "ollama")
message_content = ollama_response.get("message", {}).get("content", "")
tool_calls = ollama_response.get("message", {}).get("tool_calls", None)
openai_tool_calls = None
if tool_calls:
openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls)
data = ollama_response
usage = convert_ollama_usage_to_openai(data)
response = openai_chat_completion_message_template( response = openai_chat_completion_message_template(
model, message_content, openai_tool_calls, usage model, message_content, openai_tool_calls, usage
) )
@ -85,7 +104,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
data = json.loads(data) data = json.loads(data)
model = data.get("model", "ollama") model = data.get("model", "ollama")
message_content = data.get("message", {}).get("content", "") message_content = data.get("message", {}).get("content", None)
tool_calls = data.get("message", {}).get("tool_calls", None) tool_calls = data.get("message", {}).get("tool_calls", None)
openai_tool_calls = None openai_tool_calls = None
@ -96,48 +115,10 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
usage = None usage = None
if done: if done:
usage = { usage = convert_ollama_usage_to_openai(data)
"response_token/s": (
round(
(
(
data.get("eval_count", 0)
/ ((data.get("eval_duration", 0) / 10_000_000))
)
* 100
),
2,
)
if data.get("eval_duration", 0) > 0
else "N/A"
),
"prompt_token/s": (
round(
(
(
data.get("prompt_eval_count", 0)
/ ((data.get("prompt_eval_duration", 0) / 10_000_000))
)
* 100
),
2,
)
if data.get("prompt_eval_duration", 0) > 0
else "N/A"
),
"total_duration": data.get("total_duration", 0),
"load_duration": data.get("load_duration", 0),
"prompt_eval_count": data.get("prompt_eval_count", 0),
"prompt_eval_duration": data.get("prompt_eval_duration", 0),
"eval_count": data.get("eval_count", 0),
"eval_duration": data.get("eval_duration", 0),
"approximate_total": (
lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s"
)((data.get("total_duration", 0) or 0) // 1_000_000_000),
}
data = openai_chat_chunk_message_template( data = openai_chat_chunk_message_template(
model, message_content if not done else None, openai_tool_calls, usage model, message_content, openai_tool_calls, usage
) )
line = f"data: {json.dumps(data)}\n\n" line = f"data: {json.dumps(data)}\n\n"

View file

@ -22,7 +22,7 @@ def get_task_model_id(
# Set the task model # Set the task model
task_model_id = default_model_id task_model_id = default_model_id
# Check if the user has a custom task model and use that model # Check if the user has a custom task model and use that model
if models[task_model_id]["owned_by"] == "ollama": if models[task_model_id].get("owned_by") == "ollama":
if task_model and task_model in models: if task_model and task_model in models:
task_model_id = task_model task_model_id = task_model
else: else:

View file

@ -1,10 +1,10 @@
fastapi==0.115.7 fastapi==0.115.7
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
pydantic==2.9.2 pydantic==2.10.6
python-multipart==0.0.18 python-multipart==0.0.18
python-socketio==5.11.3 python-socketio==5.11.3
python-jose==3.3.0 python-jose==3.4.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
requests==2.32.3 requests==2.32.3
@ -31,6 +31,9 @@ APScheduler==3.10.4
RestrictedPython==8.0 RestrictedPython==8.0
loguru==0.7.2
asgiref==3.8.1
# AI libraries # AI libraries
openai openai
anthropic anthropic
@ -45,7 +48,7 @@ chromadb==0.6.2
pymilvus==2.5.0 pymilvus==2.5.0
qdrant-client~=1.12.0 qdrant-client~=1.12.0
opensearch-py==2.8.0 opensearch-py==2.8.0
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
transformers transformers
sentence-transformers==3.3.1 sentence-transformers==3.3.1
@ -59,7 +62,7 @@ fpdf2==2.8.2
pymdown-extensions==10.14.2 pymdown-extensions==10.14.2
docx2txt==0.8 docx2txt==0.8
python-pptx==1.0.0 python-pptx==1.0.0
unstructured==0.16.11 unstructured==0.16.17
nltk==3.9.1 nltk==3.9.1
Markdown==3.7 Markdown==3.7
pypandoc==1.13 pypandoc==1.13
@ -71,6 +74,7 @@ validators==0.34.0
psutil psutil
sentencepiece sentencepiece
soundfile==0.13.1 soundfile==0.13.1
azure-ai-documentintelligence==1.0.0
opencv-python-headless==4.11.0.86 opencv-python-headless==4.11.0.86
rapidocr-onnxruntime==1.3.24 rapidocr-onnxruntime==1.3.24
@ -103,5 +107,12 @@ pytest-docker~=3.1.1
googleapis-common-protos==1.63.2 googleapis-common-protos==1.63.2
google-cloud-storage==2.19.0 google-cloud-storage==2.19.0
azure-identity==1.20.0
azure-storage-blob==12.24.1
## LDAP ## LDAP
ldap3==2.9.1 ldap3==2.9.1
## Firecrawl
firecrawl-py==1.12.0

View file

@ -3,6 +3,17 @@
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
cd "$SCRIPT_DIR" || exit cd "$SCRIPT_DIR" || exit
# Add conditional Playwright browser installation
if [[ "${RAG_WEB_LOADER_ENGINE,,}" == "playwright" ]]; then
if [[ -z "${PLAYWRIGHT_WS_URI}" ]]; then
echo "Installing Playwright browsers..."
playwright install chromium
playwright install-deps chromium
fi
python -c "import nltk; nltk.download('punkt_tab')"
fi
KEY_FILE=.webui_secret_key KEY_FILE=.webui_secret_key
PORT="${PORT:-8080}" PORT="${PORT:-8080}"

View file

@ -6,6 +6,17 @@ SETLOCAL ENABLEDELAYEDEXPANSION
SET "SCRIPT_DIR=%~dp0" SET "SCRIPT_DIR=%~dp0"
cd /d "%SCRIPT_DIR%" || exit /b cd /d "%SCRIPT_DIR%" || exit /b
:: Add conditional Playwright browser installation
IF /I "%RAG_WEB_LOADER_ENGINE%" == "playwright" (
IF "%PLAYWRIGHT_WS_URI%" == "" (
echo Installing Playwright browsers...
playwright install chromium
playwright install-deps chromium
)
python -c "import nltk; nltk.download('punkt_tab')"
)
SET "KEY_FILE=.webui_secret_key" SET "KEY_FILE=.webui_secret_key"
IF "%PORT%"=="" SET PORT=8080 IF "%PORT%"=="" SET PORT=8080
IF "%HOST%"=="" SET HOST=0.0.0.0 IF "%HOST%"=="" SET HOST=0.0.0.0

View file

@ -0,0 +1,10 @@
services:
playwright:
image: mcr.microsoft.com/playwright:v1.49.1-noble # Version must match requirements.txt
container_name: playwright
command: npx -y playwright@1.49.1 run-server --port 3000 --host 0.0.0.0
open-webui:
environment:
- 'RAG_WEB_LOADER_ENGINE=playwright'
- 'PLAYWRIGHT_WS_URI=ws://playwright:3000'

229
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.5.12", "version": "0.5.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.5.12", "version": "0.5.18",
"dependencies": { "dependencies": {
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.6", "@codemirror/lang-python": "^6.1.6",
@ -41,6 +41,7 @@
"i18next-resources-to-backend": "^1.2.0", "i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1", "idb": "^7.1.1",
"js-sha256": "^0.10.1", "js-sha256": "^0.10.1",
"jspdf": "^3.0.0",
"katex": "^0.16.21", "katex": "^0.16.21",
"kokoro-js": "^1.1.1", "kokoro-js": "^1.1.1",
"marked": "^9.1.0", "marked": "^9.1.0",
@ -63,6 +64,7 @@
"svelte-sonner": "^0.3.19", "svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.2.0", "turndown": "^7.2.0",
"undici": "^7.3.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vite-plugin-static-copy": "^2.2.0" "vite-plugin-static-copy": "^2.2.0"
}, },
@ -134,9 +136,10 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.24.1", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.1.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
"integrity": "sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==", "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
"license": "MIT",
"dependencies": { "dependencies": {
"regenerator-runtime": "^0.14.0" "regenerator-runtime": "^0.14.0"
}, },
@ -3169,6 +3172,13 @@
"integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==",
"dev": true "dev": true
}, },
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -3198,6 +3208,13 @@
"integrity": "sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==", "integrity": "sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==",
"dev": true "dev": true
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "2.0.10", "version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
@ -3793,6 +3810,18 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/atob": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"atob": "bin/atob.js"
},
"engines": {
"node": ">= 4.5.0"
}
},
"node_modules/aws-sign2": { "node_modules/aws-sign2": {
"version": "0.7.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
@ -3828,6 +3857,16 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -4032,6 +4071,18 @@
"node": "10.* || >= 12.*" "node": "10.* || >= 12.*"
} }
}, },
"node_modules/btoa": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"btoa": "bin/btoa.js"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/buffer": { "node_modules/buffer": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@ -4130,6 +4181,33 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/canvg": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.10.tgz",
"integrity": "sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/canvg/node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/caseless": { "node_modules/caseless": {
"version": "0.12.0", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
@ -4598,6 +4676,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/core-js": {
"version": "3.40.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.40.0.tgz",
"integrity": "sha512-7vsMc/Lty6AGnn7uFpYT56QesI5D2Y/UkgKounk87OP9Z2H9Z8kj6jzcSGAxFmUtDOS0ntK6lbQz+Nsa0Jj6mQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@ -4642,6 +4732,16 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-select": { "node_modules/css-select": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
@ -6105,6 +6205,12 @@
"pend": "~1.2.0" "pend": "~1.2.0"
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/figures": { "node_modules/figures": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@ -6700,6 +6806,20 @@
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@ -7212,6 +7332,34 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jspdf": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.0.tgz",
"integrity": "sha512-QvuQZvOI8CjfjVgtajdL0ihrDYif1cN5gXiF9lb9Pd9JOpmocvnNyFO9sdiJ/8RA5Bu8zyGOUjJLj5kiku16ug==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.6",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jspdf/node_modules/dompurify": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
"integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/jsprim": { "node_modules/jsprim": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz",
@ -9028,7 +9176,7 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"dev": true "devOptional": true
}, },
"node_modules/periscopic": { "node_modules/periscopic": {
"version": "3.1.0", "version": "3.1.0",
@ -9754,6 +9902,16 @@
"rimraf": "bin.js" "rimraf": "bin.js"
} }
}, },
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -9893,6 +10051,16 @@
"integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==",
"dev": true "dev": true
}, },
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rimraf": { "node_modules/rimraf": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@ -10784,6 +10952,16 @@
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true "dev": true
}, },
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/std-env": { "node_modules/std-env": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz",
@ -11165,6 +11343,16 @@
"@types/estree": "*" "@types/estree": "*"
} }
}, },
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/symlink-or-copy": { "node_modules/symlink-or-copy": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz", "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz",
@ -11257,6 +11445,16 @@
"streamx": "^2.12.5" "streamx": "^2.12.5"
} }
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/text-table": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -11528,6 +11726,15 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/undici": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.3.0.tgz",
"integrity": "sha512-Qy96NND4Dou5jKoSJ2gm8ax8AJM/Ey9o9mz7KN1bb9GP+G0l20Zw8afxTnY2f4b7hmhn/z8aC2kfArVQlAhFBw==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "5.26.5", "version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
@ -11587,6 +11794,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true "dev": true
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.5.12", "version": "0.5.18",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
@ -84,6 +84,7 @@
"i18next-resources-to-backend": "^1.2.0", "i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1", "idb": "^7.1.1",
"js-sha256": "^0.10.1", "js-sha256": "^0.10.1",
"jspdf": "^3.0.0",
"katex": "^0.16.21", "katex": "^0.16.21",
"kokoro-js": "^1.1.1", "kokoro-js": "^1.1.1",
"marked": "^9.1.0", "marked": "^9.1.0",
@ -106,6 +107,7 @@
"svelte-sonner": "^0.3.19", "svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.2.0", "turndown": "^7.2.0",
"undici": "^7.3.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vite-plugin-static-copy": "^2.2.0" "vite-plugin-static-copy": "^2.2.0"
}, },

View file

@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {}, '@tailwindcss/postcss': {}
} }
}; };

View file

@ -8,11 +8,11 @@ license = { file = "LICENSE" }
dependencies = [ dependencies = [
"fastapi==0.115.7", "fastapi==0.115.7",
"uvicorn[standard]==0.30.6", "uvicorn[standard]==0.30.6",
"pydantic==2.9.2", "pydantic==2.10.6",
"python-multipart==0.0.18", "python-multipart==0.0.18",
"python-socketio==5.11.3", "python-socketio==5.11.3",
"python-jose==3.3.0", "python-jose==3.4.0",
"passlib[bcrypt]==1.7.4", "passlib[bcrypt]==1.7.4",
"requests==2.32.3", "requests==2.32.3",
@ -40,6 +40,9 @@ dependencies = [
"RestrictedPython==8.0", "RestrictedPython==8.0",
"loguru==0.7.2",
"asgiref==3.8.1",
"openai", "openai",
"anthropic", "anthropic",
"google-generativeai==0.7.2", "google-generativeai==0.7.2",
@ -53,6 +56,7 @@ dependencies = [
"pymilvus==2.5.0", "pymilvus==2.5.0",
"qdrant-client~=1.12.0", "qdrant-client~=1.12.0",
"opensearch-py==2.8.0", "opensearch-py==2.8.0",
"playwright==1.49.1",
"transformers", "transformers",
"sentence-transformers==3.3.1", "sentence-transformers==3.3.1",
@ -65,7 +69,7 @@ dependencies = [
"pymdown-extensions==10.14.2", "pymdown-extensions==10.14.2",
"docx2txt==0.8", "docx2txt==0.8",
"python-pptx==1.0.0", "python-pptx==1.0.0",
"unstructured==0.16.11", "unstructured==0.16.17",
"nltk==3.9.1", "nltk==3.9.1",
"Markdown==3.7", "Markdown==3.7",
"pypandoc==1.13", "pypandoc==1.13",
@ -77,6 +81,7 @@ dependencies = [
"psutil", "psutil",
"sentencepiece", "sentencepiece",
"soundfile==0.13.1", "soundfile==0.13.1",
"azure-ai-documentintelligence==1.0.0",
"opencv-python-headless==4.11.0.86", "opencv-python-headless==4.11.0.86",
"rapidocr-onnxruntime==1.3.24", "rapidocr-onnxruntime==1.3.24",
@ -108,7 +113,13 @@ dependencies = [
"googleapis-common-protos==1.63.2", "googleapis-common-protos==1.63.2",
"google-cloud-storage==2.19.0", "google-cloud-storage==2.19.0",
"azure-identity==1.20.0",
"azure-storage-blob==12.24.1",
"ldap3==2.9.1", "ldap3==2.9.1",
"firecrawl-py==1.12.0",
"gcp-storage-emulator>=2024.8.3", "gcp-storage-emulator>=2024.8.3",
] ]
readme = "README.md" readme = "README.md"

View file

@ -74,6 +74,7 @@ usage() {
echo " --enable-api[port=PORT] Enable API and expose it on the specified port." echo " --enable-api[port=PORT] Enable API and expose it on the specified port."
echo " --webui[port=PORT] Set the port for the web user interface." echo " --webui[port=PORT] Set the port for the web user interface."
echo " --data[folder=PATH] Bind mount for ollama data folder (by default will create the 'ollama' volume)." echo " --data[folder=PATH] Bind mount for ollama data folder (by default will create the 'ollama' volume)."
echo " --playwright Enable Playwright support for web scraping."
echo " --build Build the docker image before running the compose project." echo " --build Build the docker image before running the compose project."
echo " --drop Drop the compose project." echo " --drop Drop the compose project."
echo " -q, --quiet Run script in headless mode." echo " -q, --quiet Run script in headless mode."
@ -100,6 +101,7 @@ webui_port=3000
headless=false headless=false
build_image=false build_image=false
kill_compose=false kill_compose=false
enable_playwright=false
# Function to extract value from the parameter # Function to extract value from the parameter
extract_value() { extract_value() {
@ -129,6 +131,9 @@ while [[ $# -gt 0 ]]; do
value=$(extract_value "$key") value=$(extract_value "$key")
data_dir=${value:-"./ollama-data"} data_dir=${value:-"./ollama-data"}
;; ;;
--playwright)
enable_playwright=true
;;
--drop) --drop)
kill_compose=true kill_compose=true
;; ;;
@ -182,6 +187,9 @@ else
DEFAULT_COMPOSE_COMMAND+=" -f docker-compose.data.yaml" DEFAULT_COMPOSE_COMMAND+=" -f docker-compose.data.yaml"
export OLLAMA_DATA_DIR=$data_dir # Set OLLAMA_DATA_DIR environment variable export OLLAMA_DATA_DIR=$data_dir # Set OLLAMA_DATA_DIR environment variable
fi fi
if [[ $enable_playwright == true ]]; then
DEFAULT_COMPOSE_COMMAND+=" -f docker-compose.playwright.yaml"
fi
if [[ -n $webui_port ]]; then if [[ -n $webui_port ]]; then
export OPEN_WEBUI_PORT=$webui_port # Set OPEN_WEBUI_PORT environment variable export OPEN_WEBUI_PORT=$webui_port # Set OPEN_WEBUI_PORT environment variable
fi fi
@ -201,6 +209,7 @@ echo -e " ${GREEN}${BOLD}GPU Count:${NC} ${OLLAMA_GPU_COUNT:-Not Enabled}"
echo -e " ${GREEN}${BOLD}WebAPI Port:${NC} ${OLLAMA_WEBAPI_PORT:-Not Enabled}" echo -e " ${GREEN}${BOLD}WebAPI Port:${NC} ${OLLAMA_WEBAPI_PORT:-Not Enabled}"
echo -e " ${GREEN}${BOLD}Data Folder:${NC} ${data_dir:-Using ollama volume}" echo -e " ${GREEN}${BOLD}Data Folder:${NC} ${data_dir:-Using ollama volume}"
echo -e " ${GREEN}${BOLD}WebUI Port:${NC} $webui_port" echo -e " ${GREEN}${BOLD}WebUI Port:${NC} $webui_port"
echo -e " ${GREEN}${BOLD}Playwright:${NC} ${enable_playwright:-false}"
echo echo
if [[ $headless == true ]]; then if [[ $headless == true ]]; then

View file

@ -16,8 +16,39 @@ const packages = [
]; ];
import { loadPyodide } from 'pyodide'; import { loadPyodide } from 'pyodide';
import { setGlobalDispatcher, ProxyAgent } from 'undici';
import { writeFile, readFile, copyFile, readdir, rmdir } from 'fs/promises'; import { writeFile, readFile, copyFile, readdir, rmdir } from 'fs/promises';
/**
* Loading network proxy configurations from the environment variables.
* And the proxy config with lowercase name has the highest priority to use.
*/
function initNetworkProxyFromEnv() {
// we assume all subsequent requests in this script are HTTPS:
// https://cdn.jsdelivr.net
// https://pypi.org
// https://files.pythonhosted.org
const allProxy = process.env.all_proxy || process.env.ALL_PROXY;
const httpsProxy = process.env.https_proxy || process.env.HTTPS_PROXY;
const httpProxy = process.env.http_proxy || process.env.HTTP_PROXY;
const preferedProxy = httpsProxy || allProxy || httpProxy;
/**
* use only http(s) proxy because socks5 proxy is not supported currently:
* @see https://github.com/nodejs/undici/issues/2224
*/
if (!preferedProxy || !preferedProxy.startsWith('http')) return;
let preferedProxyURL;
try {
preferedProxyURL = new URL(preferedProxy).toString();
} catch {
console.warn(`Invalid network proxy URL: "${preferedProxy}"`);
return;
}
const dispatcher = new ProxyAgent({ uri: preferedProxyURL });
setGlobalDispatcher(dispatcher);
console.log(`Initialized network proxy "${preferedProxy}" from env`);
}
async function downloadPackages() { async function downloadPackages() {
console.log('Setting up pyodide + micropip'); console.log('Setting up pyodide + micropip');
@ -84,5 +115,6 @@ async function copyPyodide() {
} }
} }
initNetworkProxyFromEnv();
await downloadPackages(); await downloadPackages();
await copyPyodide(); await copyPyodide();

View file

@ -55,11 +55,11 @@ math {
} }
.markdown-prose { .markdown-prose {
@apply prose dark:prose-invert prose-blockquote:border-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-l-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; @apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
} }
.markdown-prose-xs { .markdown-prose-xs {
@apply text-xs prose dark:prose-invert prose-blockquote:border-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-l-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; @apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
} }
.markdown a { .markdown a {
@ -101,7 +101,7 @@ li p {
/* Dark theme scrollbar styles */ /* Dark theme scrollbar styles */
.dark ::-webkit-scrollbar-thumb { .dark ::-webkit-scrollbar-thumb {
background-color: rgba(33, 33, 33, 0.8); /* Darker color for dark theme */ background-color: rgba(42, 42, 42, 0.8); /* Darker color for dark theme */
border-color: rgba(0, 0, 0, var(--tw-border-opacity)); border-color: rgba(0, 0, 0, var(--tw-border-opacity));
} }

View file

@ -115,10 +115,10 @@ export const setDirectConnectionsConfig = async (token: string, config: object)
return res; return res;
}; };
export const getCodeInterpreterConfig = async (token: string) => { export const getCodeExecutionConfig = async (token: string) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_interpreter`, { const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_execution`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -142,10 +142,10 @@ export const getCodeInterpreterConfig = async (token: string) => {
return res; return res;
}; };
export const setCodeInterpreterConfig = async (token: string, config: object) => { export const setCodeExecutionConfig = async (token: string, config: object) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_interpreter`, { const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_execution`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View file

@ -32,9 +32,15 @@ type ChunkConfigForm = {
chunk_overlap: number; chunk_overlap: number;
}; };
type DocumentIntelligenceConfigForm = {
key: string;
endpoint: string;
};
type ContentExtractConfigForm = { type ContentExtractConfigForm = {
engine: string; engine: string;
tika_server_url: string | null; tika_server_url: string | null;
document_intelligence_config: DocumentIntelligenceConfigForm | null;
}; };
type YoutubeConfigForm = { type YoutubeConfigForm = {
@ -46,6 +52,7 @@ type YoutubeConfigForm = {
type RAGConfigForm = { type RAGConfigForm = {
pdf_extract_images?: boolean; pdf_extract_images?: boolean;
enable_google_drive_integration?: boolean; enable_google_drive_integration?: boolean;
enable_onedrive_integration?: boolean;
chunk?: ChunkConfigForm; chunk?: ChunkConfigForm;
content_extraction?: ContentExtractConfigForm; content_extraction?: ContentExtractConfigForm;
web_loader_ssl_verification?: boolean; web_loader_ssl_verification?: boolean;

View file

@ -284,14 +284,16 @@ export const updateUserInfo = async (token: string, info: object) => {
export const getAndUpdateUserLocation = async (token: string) => { export const getAndUpdateUserLocation = async (token: string) => {
const location = await getUserPosition().catch((err) => { const location = await getUserPosition().catch((err) => {
throw err; console.log(err);
return null;
}); });
if (location) { if (location) {
await updateUserInfo(token, { location: location }); await updateUserInfo(token, { location: location });
return location; return location;
} else { } else {
throw new Error('Failed to get user location'); console.log('Failed to get user location');
return null;
} }
}; };

View file

@ -1,12 +1,13 @@
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
export const getGravatarUrl = async (email: string) => { export const getGravatarUrl = async (token: string, email: string) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/gravatar?email=${email}`, { const res = await fetch(`${WEBUI_API_BASE_URL}/utils/gravatar?email=${email}`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
} }
}) })
.then(async (res) => { .then(async (res) => {
@ -22,13 +23,14 @@ export const getGravatarUrl = async (email: string) => {
return res; return res;
}; };
export const formatPythonCode = async (code: string) => { export const executeCode = async (token: string, code: string) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/format`, { const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/execute`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
code: code code: code
@ -55,13 +57,48 @@ export const formatPythonCode = async (code: string) => {
return res; return res;
}; };
export const downloadChatAsPDF = async (title: string, messages: object[]) => { export const formatPythonCode = async (token: string, code: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/format`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
code: code
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
if (err.detail) {
error = err.detail;
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const downloadChatAsPDF = async (token: string, title: string, messages: object[]) => {
let error = null; let error = null;
const blob = await fetch(`${WEBUI_API_BASE_URL}/utils/pdf`, { const blob = await fetch(`${WEBUI_API_BASE_URL}/utils/pdf`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
title: title, title: title,
@ -81,13 +118,14 @@ export const downloadChatAsPDF = async (title: string, messages: object[]) => {
return blob; return blob;
}; };
export const getHTMLFromMarkdown = async (md: string) => { export const getHTMLFromMarkdown = async (token: string, md: string) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/markdown`, { const res = await fetch(`${WEBUI_API_BASE_URL}/utils/markdown`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
md: md md: md

View file

@ -131,7 +131,9 @@
</div> </div>
</div> </div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"> <div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
>
{#if (feedbacks ?? []).length === 0} {#if (feedbacks ?? []).length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1"> <div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
{$i18n.t('No feedbacks found')} {$i18n.t('No feedbacks found')}

View file

@ -300,7 +300,9 @@
</div> </div>
</div> </div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"> <div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
>
{#if loadingLeaderboard} {#if loadingLeaderboard}
<div class=" absolute top-0 bottom-0 left-0 right-0 flex"> <div class=" absolute top-0 bottom-0 left-0 right-0 flex">
<div class="m-auto"> <div class="m-auto">

View file

@ -1,8 +1,7 @@
<script> <script>
import { getContext, createEventDispatcher, onMount, tick } from 'svelte'; import { getContext, onMount, tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import CodeEditor from '$lib/components/common/CodeEditor.svelte'; import CodeEditor from '$lib/components/common/CodeEditor.svelte';
@ -15,6 +14,8 @@
let loading = false; let loading = false;
let showConfirm = false; let showConfirm = false;
export let onSave = () => {};
export let edit = false; export let edit = false;
export let clone = false; export let clone = false;
@ -256,7 +257,7 @@ class Pipe:
const saveHandler = async () => { const saveHandler = async () => {
loading = true; loading = true;
dispatch('save', { onSave({
id, id,
name, name,
meta, meta,
@ -371,10 +372,10 @@ class Pipe:
value={content} value={content}
lang="python" lang="python"
{boilerplate} {boilerplate}
on:change={(e) => { onChange={(e) => {
_content = e.detail.value; _content = e;
}} }}
on:save={async () => { onSave={async () => {
if (formElement) { if (formElement) {
formElement.requestSubmit(); formElement.requestSubmit();
} }

View file

@ -19,7 +19,7 @@
import ChartBar from '../icons/ChartBar.svelte'; import ChartBar from '../icons/ChartBar.svelte';
import DocumentChartBar from '../icons/DocumentChartBar.svelte'; import DocumentChartBar from '../icons/DocumentChartBar.svelte';
import Evaluations from './Settings/Evaluations.svelte'; import Evaluations from './Settings/Evaluations.svelte';
import CodeInterpreter from './Settings/CodeInterpreter.svelte'; import CodeExecution from './Settings/CodeExecution.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -191,11 +191,11 @@
<button <button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab === class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'code-interpreter' 'code-execution'
? '' ? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}" : ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => { on:click={() => {
selectedTab = 'code-interpreter'; selectedTab = 'code-execution';
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
@ -212,7 +212,7 @@
/> />
</svg> </svg>
</div> </div>
<div class=" self-center">{$i18n.t('Code Interpreter')}</div> <div class=" self-center">{$i18n.t('Code Execution')}</div>
</button> </button>
<button <button
@ -391,8 +391,8 @@
await config.set(await getBackendConfig()); await config.set(await getBackendConfig());
}} }}
/> />
{:else if selectedTab === 'code-interpreter'} {:else if selectedTab === 'code-execution'}
<CodeInterpreter <CodeExecution
saveHandler={async () => { saveHandler={async () => {
toast.success($i18n.t('Settings saved successfully!')); toast.success($i18n.t('Settings saved successfully!'));

View file

@ -0,0 +1,317 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { getCodeExecutionConfig, setCodeExecutionConfig } from '$lib/apis/configs';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Switch from '$lib/components/common/Switch.svelte';
const i18n = getContext('i18n');
export let saveHandler: Function;
let config = null;
let engines = ['pyodide', 'jupyter'];
const submitHandler = async () => {
const res = await setCodeExecutionConfig(localStorage.token, config);
};
onMount(async () => {
const res = await getCodeExecutionConfig(localStorage.token);
if (res) {
config = res;
}
});
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={async () => {
await submitHandler();
saveHandler();
}}
>
<div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full">
{#if config}
<div>
<div class="mb-3.5">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="mb-2.5">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Code Execution Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.CODE_EXECUTION_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
>
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
{#each engines as engine}
<option value={engine}>{engine}</option>
{/each}
</select>
</div>
</div>
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
<div class="text-gray-500 text-xs">
{$i18n.t(
'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.'
)}
</div>
{/if}
</div>
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class="text-xs font-medium">
{$i18n.t('Jupyter URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full text-sm py-0.5 placeholder:text-gray-300 dark:placeholder:text-gray-700 bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Enter Jupyter URL')}
bind:value={config.CODE_EXECUTION_JUPYTER_URL}
autocomplete="off"
/>
</div>
</div>
</div>
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class=" flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Jupyter Auth')}
</div>
<div>
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
<option selected value="">{$i18n.t('None')}</option>
<option value="token">{$i18n.t('Token')}</option>
<option value="password">{$i18n.t('Password')}</option>
</select>
</div>
</div>
{#if config.CODE_EXECUTION_JUPYTER_AUTH}
<div class="flex w-full gap-2">
<div class="flex-1">
{#if config.CODE_EXECUTION_JUPYTER_AUTH === 'password'}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Password')}
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD}
autocomplete="off"
/>
{:else}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Token')}
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN}
autocomplete="off"
/>
{/if}
</div>
</div>
{/if}
</div>
<div class="flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Code Execution Timeout')}
</div>
<div class="">
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
<input
class="dark:bg-gray-900 w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
type="number"
bind:value={config.CODE_EXECUTION_JUPYTER_TIMEOUT}
placeholder={$i18n.t('e.g. 60')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
{/if}
</div>
<div class="mb-3.5">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('Code Interpreter')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="mb-2.5">
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enable Code Interpreter')}
</div>
<Switch bind:state={config.ENABLE_CODE_INTERPRETER} />
</div>
</div>
{#if config.ENABLE_CODE_INTERPRETER}
<div class="mb-2.5">
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Code Interpreter Engine')}
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.CODE_INTERPRETER_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
>
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
{#each engines as engine}
<option value={engine}>{engine}</option>
{/each}
</select>
</div>
</div>
{#if config.CODE_INTERPRETER_ENGINE === 'jupyter'}
<div class="text-gray-500 text-xs">
{$i18n.t(
'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.'
)}
</div>
{/if}
</div>
{#if config.CODE_INTERPRETER_ENGINE === 'jupyter'}
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class="text-xs font-medium">
{$i18n.t('Jupyter URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full text-sm py-0.5 placeholder:text-gray-300 dark:placeholder:text-gray-700 bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Enter Jupyter URL')}
bind:value={config.CODE_INTERPRETER_JUPYTER_URL}
autocomplete="off"
/>
</div>
</div>
</div>
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class="flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Jupyter Auth')}
</div>
<div>
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
<option selected value="">{$i18n.t('None')}</option>
<option value="token">{$i18n.t('Token')}</option>
<option value="password">{$i18n.t('Password')}</option>
</select>
</div>
</div>
{#if config.CODE_INTERPRETER_JUPYTER_AUTH}
<div class="flex w-full gap-2">
<div class="flex-1">
{#if config.CODE_INTERPRETER_JUPYTER_AUTH === 'password'}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Password')}
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD}
autocomplete="off"
/>
{:else}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Token')}
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN}
autocomplete="off"
/>
{/if}
</div>
</div>
{/if}
</div>
<div class="flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Code Execution Timeout')}
</div>
<div class="">
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
<input
class="dark:bg-gray-900 w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
type="number"
bind:value={config.CODE_INTERPRETER_JUPYTER_TIMEOUT}
placeholder={$i18n.t('e.g. 60')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
{/if}
<hr class="border-gray-100 dark:border-gray-850 my-2" />
<div>
<div class="py-0.5 w-full">
<div class=" mb-2.5 text-xs font-medium">
{$i18n.t('Code Interpreter Prompt Template')}
</div>
<Tooltip
content={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
placement="top-start"
>
<Textarea
bind:value={config.CODE_INTERPRETER_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</form>

View file

@ -1,166 +0,0 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { getCodeInterpreterConfig, setCodeInterpreterConfig } from '$lib/apis/configs';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Switch from '$lib/components/common/Switch.svelte';
const i18n = getContext('i18n');
export let saveHandler: Function;
let config = null;
let engines = ['pyodide', 'jupyter'];
const submitHandler = async () => {
const res = await setCodeInterpreterConfig(localStorage.token, config);
};
onMount(async () => {
const res = await getCodeInterpreterConfig(localStorage.token);
if (res) {
config = res;
}
});
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={async () => {
await submitHandler();
saveHandler();
}}
>
<div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full">
{#if config}
<div>
<div class=" mb-1 text-sm font-medium">
{$i18n.t('Code Interpreter')}
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enable Code Interpreter')}
</div>
<Switch bind:state={config.ENABLE_CODE_INTERPRETER} />
</div>
</div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Code Interpreter Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.CODE_INTERPRETER_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
>
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
{#each engines as engine}
<option value={engine}>{engine}</option>
{/each}
</select>
</div>
</div>
{#if config.CODE_INTERPRETER_ENGINE === 'jupyter'}
<div class="mt-1 flex flex-col gap-1.5 mb-1 w-full">
<div class="text-xs font-medium">
{$i18n.t('Jupyter URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full text-sm py-0.5 placeholder:text-gray-300 dark:placeholder:text-gray-700 bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Enter Jupyter URL')}
bind:value={config.CODE_INTERPRETER_JUPYTER_URL}
autocomplete="off"
/>
</div>
</div>
</div>
<div class="mt-1 flex gap-2 mb-1 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Jupyter Auth')}
</div>
<div>
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
<option selected value="">{$i18n.t('None')}</option>
<option value="token">{$i18n.t('Token')}</option>
<option value="password">{$i18n.t('Password')}</option>
</select>
</div>
</div>
{#if config.CODE_INTERPRETER_JUPYTER_AUTH}
<div class="flex w-full gap-2">
<div class="flex-1">
{#if config.CODE_INTERPRETER_JUPYTER_AUTH === 'password'}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Password')}
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD}
autocomplete="off"
/>
{:else}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Token')}
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN}
autocomplete="off"
/>
{/if}
</div>
</div>
{/if}
{/if}
</div>
<hr class="border-gray-100 dark:border-gray-850 my-2" />
<div>
<div class="py-0.5 w-full">
<div class=" mb-2.5 text-xs font-medium">
{$i18n.t('Code Interpreter Prompt Template')}
</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<Textarea
bind:value={config.CODE_INTERPRETER_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
</div>
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</form>

View file

@ -274,6 +274,7 @@
newConfig[newIdx] = OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1]; newConfig[newIdx] = OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
}); });
OPENAI_API_CONFIGS = newConfig; OPENAI_API_CONFIGS = newConfig;
updateOpenAIHandler();
}} }}
/> />
{/each} {/each}

View file

@ -27,7 +27,6 @@
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import { text } from '@sveltejs/kit';
import Textarea from '$lib/components/common/Textarea.svelte'; import Textarea from '$lib/components/common/Textarea.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -50,13 +49,20 @@
let contentExtractionEngine = 'default'; let contentExtractionEngine = 'default';
let tikaServerUrl = ''; let tikaServerUrl = '';
let showTikaServerUrl = false; let showTikaServerUrl = false;
let documentIntelligenceEndpoint = '';
let documentIntelligenceKey = '';
let showDocumentIntelligenceConfig = false;
let textSplitter = ''; let textSplitter = '';
let chunkSize = 0; let chunkSize = 0;
let chunkOverlap = 0; let chunkOverlap = 0;
let pdfExtractImages = true; let pdfExtractImages = true;
let RAG_FULL_CONTEXT = false;
let BYPASS_EMBEDDING_AND_RETRIEVAL = false;
let enableGoogleDriveIntegration = false; let enableGoogleDriveIntegration = false;
let enableOneDriveIntegration = false;
let OpenAIUrl = ''; let OpenAIUrl = '';
let OpenAIKey = ''; let OpenAIKey = '';
@ -165,23 +171,36 @@
}; };
const submitHandler = async () => { const submitHandler = async () => {
if (contentExtractionEngine === 'tika' && tikaServerUrl === '') {
toast.error($i18n.t('Tika Server URL required.'));
return;
}
if (
contentExtractionEngine === 'document_intelligence' &&
(documentIntelligenceEndpoint === '' || documentIntelligenceKey === '')
) {
toast.error($i18n.t('Document Intelligence endpoint and key required.'));
return;
}
if (!BYPASS_EMBEDDING_AND_RETRIEVAL) {
await embeddingModelUpdateHandler(); await embeddingModelUpdateHandler();
if (querySettings.hybrid) { if (querySettings.hybrid) {
await rerankingModelUpdateHandler(); await rerankingModelUpdateHandler();
} }
if (contentExtractionEngine === 'tika' && tikaServerUrl === '') {
toast.error($i18n.t('Tika Server URL required.'));
return;
} }
const res = await updateRAGConfig(localStorage.token, { const res = await updateRAGConfig(localStorage.token, {
pdf_extract_images: pdfExtractImages, pdf_extract_images: pdfExtractImages,
enable_google_drive_integration: enableGoogleDriveIntegration, enable_google_drive_integration: enableGoogleDriveIntegration,
enable_onedrive_integration: enableOneDriveIntegration,
file: { file: {
max_size: fileMaxSize === '' ? null : fileMaxSize, max_size: fileMaxSize === '' ? null : fileMaxSize,
max_count: fileMaxCount === '' ? null : fileMaxCount max_count: fileMaxCount === '' ? null : fileMaxCount
}, },
RAG_FULL_CONTEXT: RAG_FULL_CONTEXT,
BYPASS_EMBEDDING_AND_RETRIEVAL: BYPASS_EMBEDDING_AND_RETRIEVAL,
chunk: { chunk: {
text_splitter: textSplitter, text_splitter: textSplitter,
chunk_overlap: chunkOverlap, chunk_overlap: chunkOverlap,
@ -189,7 +208,11 @@
}, },
content_extraction: { content_extraction: {
engine: contentExtractionEngine, engine: contentExtractionEngine,
tika_server_url: tikaServerUrl tika_server_url: tikaServerUrl,
document_intelligence_config: {
key: documentIntelligenceKey,
endpoint: documentIntelligenceEndpoint
}
} }
}); });
@ -223,7 +246,6 @@
}; };
const toggleHybridSearch = async () => { const toggleHybridSearch = async () => {
querySettings.hybrid = !querySettings.hybrid;
querySettings = await updateQuerySettings(localStorage.token, querySettings); querySettings = await updateQuerySettings(localStorage.token, querySettings);
}; };
@ -242,14 +264,21 @@
chunkSize = res.chunk.chunk_size; chunkSize = res.chunk.chunk_size;
chunkOverlap = res.chunk.chunk_overlap; chunkOverlap = res.chunk.chunk_overlap;
RAG_FULL_CONTEXT = res.RAG_FULL_CONTEXT;
BYPASS_EMBEDDING_AND_RETRIEVAL = res.BYPASS_EMBEDDING_AND_RETRIEVAL;
contentExtractionEngine = res.content_extraction.engine; contentExtractionEngine = res.content_extraction.engine;
tikaServerUrl = res.content_extraction.tika_server_url; tikaServerUrl = res.content_extraction.tika_server_url;
showTikaServerUrl = contentExtractionEngine === 'tika'; showTikaServerUrl = contentExtractionEngine === 'tika';
documentIntelligenceEndpoint = res.content_extraction.document_intelligence_config.endpoint;
documentIntelligenceKey = res.content_extraction.document_intelligence_config.key;
showDocumentIntelligenceConfig = contentExtractionEngine === 'document_intelligence';
fileMaxSize = res?.file.max_size ?? ''; fileMaxSize = res?.file.max_size ?? '';
fileMaxCount = res?.file.max_count ?? ''; fileMaxCount = res?.file.max_count ?? '';
enableGoogleDriveIntegration = res.enable_google_drive_integration; enableGoogleDriveIntegration = res.enable_google_drive_integration;
enableOneDriveIntegration = res.enable_onedrive_integration;
} }
}); });
</script> </script>
@ -289,11 +318,147 @@
}} }}
> >
<div class=" space-y-2.5 overflow-y-scroll scrollbar-hidden h-full pr-1.5"> <div class=" space-y-2.5 overflow-y-scroll scrollbar-hidden h-full pr-1.5">
<div class="flex flex-col gap-0.5"> <div class="">
<div class=" mb-0.5 text-sm font-medium">{$i18n.t('General Settings')}</div> <div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<div class=" flex w-full justify-between"> <hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" self-center text-xs font-medium">{$i18n.t('Embedding Model Engine')}</div>
<div class=" mb-2.5 flex flex-col w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Content Extraction Engine')}
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={contentExtractionEngine}
>
<option value="">{$i18n.t('Default')} </option>
<option value="tika">{$i18n.t('Tika')}</option>
<option value="document_intelligence">{$i18n.t('Document Intelligence')}</option>
</select>
</div>
</div>
{#if contentExtractionEngine === 'tika'}
<div class="flex w-full mt-1">
<div class="flex-1 mr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Tika Server URL')}
bind:value={tikaServerUrl}
/>
</div>
</div>
{:else if contentExtractionEngine === 'document_intelligence'}
<div class="my-0.5 flex gap-2 pr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Document Intelligence Endpoint')}
bind:value={documentIntelligenceEndpoint}
/>
<SensitiveInput
placeholder={$i18n.t('Enter Document Intelligence Key')}
bind:value={documentIntelligenceKey}
/>
</div>
{/if}
</div>
{#if contentExtractionEngine === ''}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('PDF Extract Images (OCR)')}
</div>
<div class="flex items-center relative">
<Switch bind:state={pdfExtractImages} />
</div>
</div>
{/if}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<Tooltip content={$i18n.t('Full Context Mode')} placement="top-start">
{$i18n.t('Bypass Embedding and Retrieval')}
</Tooltip>
</div>
<div class="flex items-center relative">
<Tooltip
content={BYPASS_EMBEDDING_AND_RETRIEVAL
? 'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.'
: 'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'}
>
<Switch bind:state={BYPASS_EMBEDDING_AND_RETRIEVAL} />
</Tooltip>
</div>
</div>
{#if !BYPASS_EMBEDDING_AND_RETRIEVAL}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Text Splitter')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={textSplitter}
>
<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
<option value="token">{$i18n.t('Token')} ({$i18n.t('Tiktoken')})</option>
</select>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" flex gap-1.5 w-full">
<div class=" w-full justify-between">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Chunk Size')}
</div>
<div class="self-center">
<input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="number"
placeholder={$i18n.t('Enter Chunk Size')}
bind:value={chunkSize}
autocomplete="off"
min="0"
/>
</div>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Chunk Overlap')}
</div>
<div class="self-center">
<input
class="w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="number"
placeholder={$i18n.t('Enter Chunk Overlap')}
bind:value={chunkOverlap}
autocomplete="off"
min="0"
/>
</div>
</div>
</div>
</div>
{/if}
</div>
{#if !BYPASS_EMBEDDING_AND_RETRIEVAL}
<div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('Embedding')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-2.5 flex flex-col w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Embedding Model Engine')}
</div>
<div class="flex items-center relative"> <div class="flex items-center relative">
<select <select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right" class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
@ -343,64 +508,17 @@
/> />
</div> </div>
{/if} {/if}
{#if embeddingEngine === 'ollama' || embeddingEngine === 'openai'}
<div class="flex mt-0.5 space-x-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Embedding Batch Size')}</div>
<div class=" flex-1">
<input
id="steps-range"
type="range"
min="1"
max="2048"
step="1"
bind:value={embeddingBatchSize}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div> </div>
<div class=" mb-2.5 flex flex-col w-full">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Embedding Model')}</div>
<div class=""> <div class="">
<input
bind:value={embeddingBatchSize}
type="number"
class=" bg-transparent text-center w-14"
min="-2"
max="16000"
step="1"
/>
</div>
</div>
{/if}
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Hybrid Search')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
toggleHybridSearch();
}}
type="button"
>
{#if querySettings.hybrid === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
<hr class="border-gray-100 dark:border-gray-850" />
<div class="space-y-2" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Embedding Model')}</div>
{#if embeddingEngine === 'ollama'} {#if embeddingEngine === 'ollama'}
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1 mr-2"> <div class="flex-1 mr-2">
<input <input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
bind:value={embeddingModel} bind:value={embeddingModel}
placeholder={$i18n.t('Set embedding model')} placeholder={$i18n.t('Set embedding model')}
required required
@ -411,7 +529,7 @@
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1 mr-2"> <div class="flex-1 mr-2">
<input <input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Set embedding model (e.g. {{model}})', { placeholder={$i18n.t('Set embedding model (e.g. {{model}})', {
model: embeddingModel.slice(-40) model: embeddingModel.slice(-40)
})} })}
@ -421,7 +539,7 @@
{#if embeddingEngine === ''} {#if embeddingEngine === ''}
<button <button
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition" class="px-2.5 bg-transparent text-gray-800 dark:bg-transparent dark:text-gray-100 rounded-lg transition"
on:click={() => { on:click={() => {
embeddingModelUpdateHandler(); embeddingModelUpdateHandler();
}} }}
@ -476,21 +594,66 @@
{/if} {/if}
</div> </div>
{/if} {/if}
</div>
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500"> <div class="mt-1 mb-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t( {$i18n.t(
'Warning: If you update or change your embedding model, you will need to re-import all documents.' 'Warning: If you update or change your embedding model, you will need to re-import all documents.'
)} )}
</div> </div>
</div>
{#if embeddingEngine === 'ollama' || embeddingEngine === 'openai'}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Embedding Batch Size')}</div>
<div class="">
<input
bind:value={embeddingBatchSize}
type="number"
class=" bg-transparent text-center w-14 outline-none"
min="-2"
max="16000"
step="1"
/>
</div>
</div>
{/if}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Full Context Mode')}</div>
<div class="flex items-center relative">
<Tooltip
content={RAG_FULL_CONTEXT
? 'Inject entire contents as context for comprehensive processing, this is recommended for complex queries.'
: 'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'}
>
<Switch bind:state={RAG_FULL_CONTEXT} />
</Tooltip>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Hybrid Search')}</div>
<div class="flex items-center relative">
<Switch
bind:state={querySettings.hybrid}
on:change={() => {
toggleHybridSearch();
}}
/>
</div>
</div>
{#if querySettings.hybrid === true} {#if querySettings.hybrid === true}
<div class=" "> <div class=" mb-2.5 flex flex-col w-full">
<div class=" mb-2 text-sm font-medium">{$i18n.t('Reranking Model')}</div> <div class=" mb-1 text-xs font-medium">{$i18n.t('Reranking Model')}</div>
<div class="">
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1 mr-2"> <div class="flex-1 mr-2">
<input <input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Set reranking model (e.g. {{model}})', { placeholder={$i18n.t('Set reranking model (e.g. {{model}})', {
model: 'BAAI/bge-reranker-v2-m3' model: 'BAAI/bge-reranker-v2-m3'
})} })}
@ -498,7 +661,7 @@
/> />
</div> </div>
<button <button
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition" class="px-2.5 bg-transparent text-gray-800 dark:bg-transparent dark:text-gray-100 rounded-lg transition"
on:click={() => { on:click={() => {
rerankingModelUpdateHandler(); rerankingModelUpdateHandler();
}} }}
@ -552,68 +715,20 @@
</button> </button>
</div> </div>
</div> </div>
</div>
{/if} {/if}
</div> </div>
<hr class=" border-gray-100 dark:border-gray-850" /> <div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('Retrieval')}</div>
<div class=""> <hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="text-sm font-medium mb-1">{$i18n.t('Content Extraction')}</div>
<div class="flex w-full justify-between"> <div class=" mb-2.5 flex w-full justify-between">
<div class="self-center text-xs font-medium">{$i18n.t('Engine')}</div> <div class=" self-center text-xs font-medium">{$i18n.t('Top K')}</div>
<div class="flex items-center relative"> <div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={contentExtractionEngine}
on:change={(e) => {
showTikaServerUrl = e.target.value === 'tika';
}}
>
<option value="">{$i18n.t('Default')} </option>
<option value="tika">{$i18n.t('Tika')}</option>
</select>
</div>
</div>
{#if showTikaServerUrl}
<div class="flex w-full mt-1">
<div class="flex-1 mr-2">
<input <input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Tika Server URL')}
bind:value={tikaServerUrl}
/>
</div>
</div>
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="text-sm font-medium mb-1">{$i18n.t('Google Drive')}</div>
<div class="">
<div class="flex justify-between items-center text-xs">
<div class="text-xs font-medium">{$i18n.t('Enable Google Drive')}</div>
<div>
<Switch bind:state={enableGoogleDriveIntegration} />
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850" />
<div class=" ">
<div class=" text-sm font-medium mb-1">{$i18n.t('Query Params')}</div>
<div class=" flex gap-1.5">
<div class="flex flex-col w-full gap-1">
<div class=" text-xs font-medium w-full">{$i18n.t('Top K')}</div>
<div class="w-full">
<input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="number" type="number"
placeholder={$i18n.t('Enter Top K')} placeholder={$i18n.t('Enter Top K')}
bind:value={querySettings.k} bind:value={querySettings.k}
@ -624,14 +739,12 @@
</div> </div>
{#if querySettings.hybrid === true} {#if querySettings.hybrid === true}
<div class=" flex flex-col w-full gap-1"> <div class=" mb-2.5 flex flex-col w-full justify-between">
<div class="text-xs font-medium w-full"> <div class=" flex w-full justify-between">
{$i18n.t('Minimum Score')} <div class=" self-center text-xs font-medium">{$i18n.t('Minimum Score')}</div>
</div> <div class="flex items-center relative">
<div class="w-full">
<input <input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="number" type="number"
step="0.01" step="0.01"
placeholder={$i18n.t('Enter Score')} placeholder={$i18n.t('Enter Score')}
@ -642,107 +755,42 @@
/> />
</div> </div>
</div> </div>
{/if} <div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
</div>
{#if querySettings.hybrid === true}
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t( {$i18n.t(
'Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.' 'Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.'
)} )}
</div> </div>
</div>
{/if} {/if}
<div class="mt-2"> <div class=" mb-2.5 flex flex-col w-full justify-between">
<div class=" mb-1 text-xs font-medium">{$i18n.t('RAG Template')}</div> <div class=" mb-1 text-xs font-medium">{$i18n.t('RAG Template')}</div>
<div class="flex w-full items-center relative">
<Tooltip <Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')} content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start" placement="top-start"
className="w-full"
> >
<Textarea <Textarea
bind:value={querySettings.template} bind:value={querySettings.template}
placeholder={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')} placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/> />
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
</div>
{/if}
<hr class=" border-gray-100 dark:border-gray-850" /> <div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('Files')}</div>
<div class=" "> <hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="mb-1 text-sm font-medium">{$i18n.t('Chunk Params')}</div>
<div class="flex w-full justify-between mb-1.5"> <div class=" mb-2.5 flex w-full justify-between">
<div class="self-center text-xs font-medium">{$i18n.t('Text Splitter')}</div> <div class=" self-center text-xs font-medium">{$i18n.t('Max Upload Size')}</div>
<div class="flex items-center relative"> <div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={textSplitter}
>
<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
<option value="token">{$i18n.t('Token')} ({$i18n.t('Tiktoken')})</option>
</select>
</div>
</div>
<div class=" flex gap-1.5">
<div class=" w-full justify-between">
<div class="self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Chunk Size')}
</div>
<div class="self-center">
<input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="number"
placeholder={$i18n.t('Enter Chunk Size')}
bind:value={chunkSize}
autocomplete="off"
min="0"
/>
</div>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Chunk Overlap')}
</div>
<div class="self-center">
<input
class="w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="number"
placeholder={$i18n.t('Enter Chunk Overlap')}
bind:value={chunkOverlap}
autocomplete="off"
min="0"
/>
</div>
</div>
</div>
<div class="my-2">
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('PDF Extract Images (OCR)')}</div>
<div>
<Switch bind:state={pdfExtractImages} />
</div>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="">
<div class="text-sm font-medium mb-1">{$i18n.t('Files')}</div>
<div class=" flex gap-1.5">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Max Upload Size')}
</div>
<div class="self-center">
<Tooltip <Tooltip
content={$i18n.t( content={$i18n.t(
'The maximum file size in MB. If the file size exceeds this limit, the file will not be uploaded.' 'The maximum file size in MB. If the file size exceeds this limit, the file will not be uploaded.'
@ -750,7 +798,7 @@
placement="top-start" placement="top-start"
> >
<input <input
class="w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="number" type="number"
placeholder={$i18n.t('Leave empty for unlimited')} placeholder={$i18n.t('Leave empty for unlimited')}
bind:value={fileMaxSize} bind:value={fileMaxSize}
@ -761,11 +809,9 @@
</div> </div>
</div> </div>
<div class=" w-full"> <div class=" mb-2.5 flex w-full justify-between">
<div class="self-center text-xs font-medium min-w-fit mb-1"> <div class=" self-center text-xs font-medium">{$i18n.t('Max Upload Count')}</div>
{$i18n.t('Max Upload Count')} <div class="flex items-center relative">
</div>
<div class="self-center">
<Tooltip <Tooltip
content={$i18n.t( content={$i18n.t(
'The maximum number of files that can be used at once in chat. If the number of files exceeds this limit, the files will not be uploaded.' 'The maximum number of files that can be used at once in chat. If the number of files exceeds this limit, the files will not be uploaded.'
@ -773,7 +819,7 @@
placement="top-start" placement="top-start"
> >
<input <input
class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="number" type="number"
placeholder={$i18n.t('Leave empty for unlimited')} placeholder={$i18n.t('Leave empty for unlimited')}
bind:value={fileMaxCount} bind:value={fileMaxCount}
@ -784,65 +830,64 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('Integration')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Google Drive')}</div>
<div class="flex items-center relative">
<Switch bind:state={enableGoogleDriveIntegration} />
</div>
</div> </div>
<hr class=" border-gray-100 dark:border-gray-850" /> <div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('OneDrive')}</div>
<div class="flex items-center relative">
<Switch bind:state={enableOneDriveIntegration} />
</div>
</div>
</div>
<div> <div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('Danger Zone')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Reset Upload Directory')}</div>
<div class="flex items-center relative">
<button <button
class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition" class="text-xs"
on:click={() => { on:click={() => {
showResetUploadDirConfirm = true; showResetUploadDirConfirm = true;
}} }}
type="button"
> >
<div class=" self-center mr-3"> {$i18n.t('Reset')}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM9.75 14.25a.75.75 0 0 0 0 1.5H15a.75.75 0 0 0 0-1.5H9.75Z"
clip-rule="evenodd"
/>
<path
d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Reset Upload Directory')}</div>
</button> </button>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Reset Vector Storage/Knowledge')}
</div>
<div class="flex items-center relative">
<button <button
class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition" class="text-xs"
on:click={() => { on:click={() => {
showResetConfirm = true; showResetConfirm = true;
}} }}
type="button"
> >
<div class=" self-center mr-3"> {$i18n.t('Reset')}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5Zm6.75 7.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">
{$i18n.t('Reset Vector Storage/Knowledge')}
</div>
</button> </button>
</div> </div>
</div> </div>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium"> <div class="flex justify-end pt-3 text-sm font-medium">
<button <button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full" class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"

View file

@ -103,10 +103,12 @@
<div class="overflow-y-scroll scrollbar-hidden h-full"> <div class="overflow-y-scroll scrollbar-hidden h-full">
{#if evaluationConfig !== null} {#if evaluationConfig !== null}
<div class=""> <div class="">
<div class="text-sm font-medium mb-2">{$i18n.t('General Settings')}</div> <div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<div class=" mb-2"> <hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="flex justify-between items-center text-xs">
<div class="mb-2.5 flex w-full justify-between">
<div class=" text-xs font-medium">{$i18n.t('Arena Models')}</div> <div class=" text-xs font-medium">{$i18n.t('Arena Models')}</div>
<Tooltip content={$i18n.t(`Message rating should be enabled to use this feature`)}> <Tooltip content={$i18n.t(`Message rating should be enabled to use this feature`)}>
@ -116,10 +118,11 @@
</div> </div>
{#if evaluationConfig.ENABLE_EVALUATION_ARENA_MODELS} {#if evaluationConfig.ENABLE_EVALUATION_ARENA_MODELS}
<hr class=" border-gray-50 dark:border-gray-700/10 my-2" /> <div class="mb-3">
<div class=" mb-2.5 text-base font-medium flex justify-between items-center">
<div class="flex justify-between items-center mb-2"> <div>
<div class="text-sm font-medium">{$i18n.t('Manage Arena Models')}</div> {$i18n.t('Manage')}
</div>
<div> <div>
<Tooltip content={$i18n.t('Add Arena Model')}> <Tooltip content={$i18n.t('Add Arena Model')}>
@ -136,6 +139,8 @@
</div> </div>
</div> </div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if (evaluationConfig?.EVALUATION_ARENA_MODELS ?? []).length > 0} {#if (evaluationConfig?.EVALUATION_ARENA_MODELS ?? []).length > 0}
{#each evaluationConfig.EVALUATION_ARENA_MODELS as model, index} {#each evaluationConfig.EVALUATION_ARENA_MODELS as model, index}
@ -157,6 +162,7 @@
</div> </div>
{/if} {/if}
</div> </div>
</div>
{/if} {/if}
</div> </div>
{:else} {:else}

View file

@ -231,11 +231,11 @@
</a> </a>
</div> </div>
<button <!-- <button
class="flex-shrink-0 text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium" class="flex-shrink-0 text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
> >
{$i18n.t('Activate')} {$i18n.t('Activate')}
</button> </button> -->
</div> </div>
</div> </div>
</div> </div>

View file

@ -261,6 +261,9 @@
} else if (config.engine === 'openai' && config.openai.OPENAI_API_KEY === '') { } else if (config.engine === 'openai' && config.openai.OPENAI_API_KEY === '') {
toast.error($i18n.t('OpenAI API Key is required.')); toast.error($i18n.t('OpenAI API Key is required.'));
config.enabled = false; config.enabled = false;
} else if (config.engine === 'gemini' && config.gemini.GEMINI_API_KEY === '') {
toast.error($i18n.t('Gemini API Key is required.'));
config.enabled = false;
} }
} }
@ -294,6 +297,7 @@
<option value="openai">{$i18n.t('Default (Open AI)')}</option> <option value="openai">{$i18n.t('Default (Open AI)')}</option>
<option value="comfyui">{$i18n.t('ComfyUI')}</option> <option value="comfyui">{$i18n.t('ComfyUI')}</option>
<option value="automatic1111">{$i18n.t('Automatic1111')}</option> <option value="automatic1111">{$i18n.t('Automatic1111')}</option>
<option value="gemini">{$i18n.t('Gemini')}</option>
</select> </select>
</div> </div>
</div> </div>
@ -605,6 +609,24 @@
/> />
</div> </div>
</div> </div>
{:else if config?.engine === 'gemini'}
<div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('Gemini API Config')}</div>
<div class="flex gap-2 mb-1">
<input
class="flex-1 w-full text-sm bg-transparent outline-none"
placeholder={$i18n.t('API Base URL')}
bind:value={config.gemini.GEMINI_API_BASE_URL}
required
/>
<SensitiveInput
placeholder={$i18n.t('API Key')}
bind:value={config.gemini.GEMINI_API_KEY}
/>
</div>
</div>
{/if} {/if}
</div> </div>

View file

@ -51,7 +51,7 @@
onMount(async () => { onMount(async () => {
taskConfig = await getTaskConfig(localStorage.token); taskConfig = await getTaskConfig(localStorage.token);
promptSuggestions = $config?.default_prompt_suggestions; promptSuggestions = $config?.default_prompt_suggestions ?? [];
banners = await getBanners(localStorage.token); banners = await getBanners(localStorage.token);
}); });

View file

@ -16,6 +16,8 @@
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Minus from '$lib/components/icons/Minus.svelte'; import Minus from '$lib/components/icons/Minus.svelte';
import Plus from '$lib/components/icons/Plus.svelte'; import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
export let show = false; export let show = false;
export let initHandler = () => {}; export let initHandler = () => {};
@ -26,6 +28,9 @@
let defaultModelIds = []; let defaultModelIds = [];
let modelIds = []; let modelIds = [];
let sortKey = '';
let sortOrder = '';
let loading = false; let loading = false;
let showResetModal = false; let showResetModal = false;
@ -71,6 +76,9 @@
// Add remaining IDs not in MODEL_ORDER_LIST, sorted alphabetically // Add remaining IDs not in MODEL_ORDER_LIST, sorted alphabetically
...allModelIds.filter((id) => !orderedSet.has(id)).sort((a, b) => a.localeCompare(b)) ...allModelIds.filter((id) => !orderedSet.has(id)).sort((a, b) => a.localeCompare(b))
]; ];
sortKey = '';
sortOrder = '';
}; };
const submitHandler = async () => { const submitHandler = async () => {
loading = true; loading = true;
@ -145,9 +153,45 @@
> >
<div> <div>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="mb-1 flex justify-between"> <button
class="mb-1 flex gap-2"
type="button"
on:click={() => {
sortKey = 'model';
if (sortOrder === 'asc') {
sortOrder = 'desc';
} else {
sortOrder = 'asc';
}
modelIds = modelIds
.filter((id) => id !== '')
.sort((a, b) => {
const nameA = $models.find((model) => model.id === a)?.name || a;
const nameB = $models.find((model) => model.id === b)?.name || b;
return sortOrder === 'desc'
? nameA.localeCompare(nameB)
: nameB.localeCompare(nameA);
});
}}
>
<div class="text-xs text-gray-500">{$i18n.t('Reorder Models')}</div> <div class="text-xs text-gray-500">{$i18n.t('Reorder Models')}</div>
</div>
{#if sortKey === 'model'}
<span class="font-normal self-center">
{#if sortOrder === 'asc'}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-3" />
</span>
{/if}
</button>
<ModelList bind:modelIds /> <ModelList bind:modelIds />
</div> </div>

View file

@ -21,14 +21,24 @@
modelIds = modelList; modelIds = modelList;
}; };
onMount(() => { $: if (modelIds) {
init();
}
const init = () => {
if (sortable) {
sortable.destroy();
}
if (modelListElement) {
sortable = Sortable.create(modelListElement, { sortable = Sortable.create(modelListElement, {
animation: 150, animation: 150,
onUpdate: async (event) => { onUpdate: async (event) => {
positionChangeHandler(); positionChangeHandler();
} }
}); });
}); }
};
</script> </script>
{#if modelIds.length > 0} {#if modelIds.length > 0}

View file

@ -6,6 +6,7 @@
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -84,23 +85,25 @@
> >
<div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full"> <div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full">
{#if webConfig} {#if webConfig}
<div> <div class="">
<div class=" mb-1 text-sm font-medium"> <div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Web Search')} {$i18n.t('Web Search')}
</div> </div>
<div class="flex items-center relative">
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enable Web Search')}
</div>
<Switch bind:state={webConfig.search.enabled} /> <Switch bind:state={webConfig.search.enabled} />
</div> </div>
</div> </div>
<div class=" py-0.5 flex w-full justify-between"> <div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Web Search Engine')}</div> <div class=" self-center text-xs font-medium">
{$i18n.t('Web Search Engine')}
</div>
<div class="flex items-center relative"> <div class="flex items-center relative">
<select <select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right" class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
@ -117,8 +120,8 @@
</div> </div>
{#if webConfig.search.engine !== ''} {#if webConfig.search.engine !== ''}
<div class="mt-1.5">
{#if webConfig.search.engine === 'searxng'} {#if webConfig.search.engine === 'searxng'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Searxng Query URL')} {$i18n.t('Searxng Query URL')}
@ -136,7 +139,9 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{:else if webConfig.search.engine === 'google_pse'} {:else if webConfig.search.engine === 'google_pse'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Google PSE API Key')} {$i18n.t('Google PSE API Key')}
@ -164,7 +169,9 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{:else if webConfig.search.engine === 'brave'} {:else if webConfig.search.engine === 'brave'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Brave Search API Key')} {$i18n.t('Brave Search API Key')}
@ -175,7 +182,9 @@
bind:value={webConfig.search.brave_search_api_key} bind:value={webConfig.search.brave_search_api_key}
/> />
</div> </div>
</div>
{:else if webConfig.search.engine === 'kagi'} {:else if webConfig.search.engine === 'kagi'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Kagi Search API Key')} {$i18n.t('Kagi Search API Key')}
@ -186,7 +195,10 @@
bind:value={webConfig.search.kagi_search_api_key} bind:value={webConfig.search.kagi_search_api_key}
/> />
</div> </div>
.
</div>
{:else if webConfig.search.engine === 'mojeek'} {:else if webConfig.search.engine === 'mojeek'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Mojeek Search API Key')} {$i18n.t('Mojeek Search API Key')}
@ -197,7 +209,9 @@
bind:value={webConfig.search.mojeek_search_api_key} bind:value={webConfig.search.mojeek_search_api_key}
/> />
</div> </div>
</div>
{:else if webConfig.search.engine === 'bocha'} {:else if webConfig.search.engine === 'bocha'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Bocha Search API Key')} {$i18n.t('Bocha Search API Key')}
@ -208,7 +222,9 @@
bind:value={webConfig.search.bocha_search_api_key} bind:value={webConfig.search.bocha_search_api_key}
/> />
</div> </div>
</div>
{:else if webConfig.search.engine === 'serpstack'} {:else if webConfig.search.engine === 'serpstack'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Serpstack API Key')} {$i18n.t('Serpstack API Key')}
@ -219,7 +235,9 @@
bind:value={webConfig.search.serpstack_api_key} bind:value={webConfig.search.serpstack_api_key}
/> />
</div> </div>
</div>
{:else if webConfig.search.engine === 'serper'} {:else if webConfig.search.engine === 'serper'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Serper API Key')} {$i18n.t('Serper API Key')}
@ -230,7 +248,9 @@
bind:value={webConfig.search.serper_api_key} bind:value={webConfig.search.serper_api_key}
/> />
</div> </div>
</div>
{:else if webConfig.search.engine === 'serply'} {:else if webConfig.search.engine === 'serply'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Serply API Key')} {$i18n.t('Serply API Key')}
@ -241,7 +261,9 @@
bind:value={webConfig.search.serply_api_key} bind:value={webConfig.search.serply_api_key}
/> />
</div> </div>
</div>
{:else if webConfig.search.engine === 'searchapi'} {:else if webConfig.search.engine === 'searchapi'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('SearchApi API Key')} {$i18n.t('SearchApi API Key')}
@ -269,7 +291,9 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{:else if webConfig.search.engine === 'serpapi'} {:else if webConfig.search.engine === 'serpapi'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('SerpApi API Key')} {$i18n.t('SerpApi API Key')}
@ -297,7 +321,9 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{:else if webConfig.search.engine === 'tavily'} {:else if webConfig.search.engine === 'tavily'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Tavily API Key')} {$i18n.t('Tavily API Key')}
@ -308,7 +334,9 @@
bind:value={webConfig.search.tavily_api_key} bind:value={webConfig.search.tavily_api_key}
/> />
</div> </div>
</div>
{:else if webConfig.search.engine === 'jina'} {:else if webConfig.search.engine === 'jina'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Jina API Key')} {$i18n.t('Jina API Key')}
@ -319,7 +347,9 @@
bind:value={webConfig.search.jina_api_key} bind:value={webConfig.search.jina_api_key}
/> />
</div> </div>
</div>
{:else if webConfig.search.engine === 'exa'} {:else if webConfig.search.engine === 'exa'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Exa API Key')} {$i18n.t('Exa API Key')}
@ -330,7 +360,9 @@
bind:value={webConfig.search.exa_api_key} bind:value={webConfig.search.exa_api_key}
/> />
</div> </div>
</div>
{:else if webConfig.search.engine === 'bing'} {:else if webConfig.search.engine === 'bing'}
<div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Bing Search V7 Endpoint')} {$i18n.t('Bing Search V7 Endpoint')}
@ -359,12 +391,13 @@
bind:value={webConfig.search.bing_search_v7_subscription_key} bind:value={webConfig.search.bing_search_v7_subscription_key}
/> />
</div> </div>
{/if}
</div> </div>
{/if} {/if}
{/if}
{#if webConfig.search.enabled} {#if webConfig.search.enabled}
<div class="mt-2 flex gap-2 mb-1"> <div class="mb-2.5 flex w-full flex-col">
<div class="flex gap-2">
<div class="w-full"> <div class="w-full">
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Search Result Count')} {$i18n.t('Search Result Count')}
@ -391,9 +424,10 @@
/> />
</div> </div>
</div> </div>
</div>
<div class="mt-2"> <div class="mb-2.5 flex w-full flex-col">
<div class=" self-center text-xs font-medium mb-1"> <div class=" text-xs font-medium mb-1">
{$i18n.t('Domain Filter List')} {$i18n.t('Domain Filter List')}
</div> </div>
@ -406,48 +440,61 @@
/> />
</div> </div>
{/if} {/if}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<Tooltip content={$i18n.t('Full Context Mode')} placement="top-start">
{$i18n.t('Bypass Embedding and Retrieval')}
</Tooltip>
</div>
<div class="flex items-center relative">
<Tooltip
content={webConfig.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
? 'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.'
: 'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'}
>
<Switch bind:state={webConfig.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL} />
</Tooltip>
</div>
</div> </div>
<hr class="border-gray-100 dark:border-gray-850 my-2" /> <div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<div> {$i18n.t('Trust Proxy Environment')}
<div class=" mb-1 text-sm font-medium"> </div>
{$i18n.t('Web Loader Settings')} <div class="flex items-center relative">
<Tooltip
content={webConfig.search.trust_env
? 'Use proxy designated by http_proxy and https_proxy environment variables to fetch page contents'
: 'Use no proxy to fetch page contents.'}
>
<Switch bind:state={webConfig.search.trust_env} />
</Tooltip>
</div>
</div>
</div> </div>
<div> <div class="mb-3">
<div class=" py-0.5 flex w-full justify-between"> <div class=" mb-2.5 text-base font-medium">{$i18n.t('Loader')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Bypass SSL verification for Websites')} {$i18n.t('Bypass SSL verification for Websites')}
</div> </div>
<div class="flex items-center relative">
<button <Switch bind:state={webConfig.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION} />
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
webConfig.web_loader_ssl_verification = !webConfig.web_loader_ssl_verification;
submitHandler();
}}
type="button"
>
{#if webConfig.web_loader_ssl_verification === false}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div> </div>
</div> </div>
<div class=" mt-2 mb-1 text-sm font-medium"> <div class=" mb-2.5 flex w-full justify-between">
{$i18n.t('Youtube Loader Settings')} <div class=" self-center text-xs font-medium">
{$i18n.t('Youtube Language')}
</div> </div>
<div class="flex items-center relative">
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" w-20 text-xs font-medium self-center">{$i18n.t('Language')}</div>
<div class=" flex-1 self-center">
<input <input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="text" type="text"
placeholder={$i18n.t('Enter language codes')} placeholder={$i18n.t('Enter language codes')}
bind:value={youtubeLanguage} bind:value={youtubeLanguage}
@ -455,14 +502,14 @@
/> />
</div> </div>
</div> </div>
</div>
<div> <div class=" mb-2.5 flex flex-col w-full justify-between">
<div class=" py-0.5 flex w-full justify-between"> <div class=" mb-1 text-xs font-medium">
<div class=" w-20 text-xs font-medium self-center">{$i18n.t('Proxy URL')}</div> {$i18n.t('Youtube Proxy URL')}
<div class=" flex-1 self-center"> </div>
<div class="flex items-center relative">
<input <input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="text" type="text"
placeholder={$i18n.t('Enter proxy URL (e.g. https://user:password@host:port)')} placeholder={$i18n.t('Enter proxy URL (e.g. https://user:password@host:port)')}
bind:value={youtubeProxyUrl} bind:value={youtubeProxyUrl}

View file

@ -85,8 +85,9 @@
return true; return true;
} else { } else {
let name = user.name.toLowerCase(); let name = user.name.toLowerCase();
let email = user.email.toLowerCase();
const query = search.toLowerCase(); const query = search.toLowerCase();
return name.includes(query); return name.includes(query) || email.includes(query);
} }
}) })
.sort((a, b) => { .sort((a, b) => {
@ -171,7 +172,9 @@
</div> </div>
</div> </div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"> <div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
>
<table <table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm" class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
> >

View file

@ -103,7 +103,9 @@
return; return;
} }
if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) { if (
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
) {
let reader = new FileReader(); let reader = new FileReader();
reader.onload = async (event) => { reader.onload = async (event) => {
@ -155,22 +157,6 @@
} }
files = [...files, fileItem]; files = [...files, fileItem];
// Check if the file is an audio file and transcribe/convert it to text file
if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
console.log(res);
const blob = new Blob([res.text], { type: 'text/plain' });
file = blobToFile(blob, `${file.name}.txt`);
fileItem.name = file.name;
fileItem.size = file.size;
}
}
try { try {
// During the file upload, file content is automatically extracted. // During the file upload, file content is automatically extracted.

View file

@ -187,15 +187,20 @@
setToolIds(); setToolIds();
} }
$: if (atSelectedModel || selectedModels) {
setToolIds();
}
const setToolIds = async () => { const setToolIds = async () => {
if (!$tools) { if (!$tools) {
tools.set(await getTools(localStorage.token)); tools.set(await getTools(localStorage.token));
} }
if (selectedModels.length !== 1) { if (selectedModels.length !== 1 && !atSelectedModel) {
return; return;
} }
const model = $models.find((m) => m.id === selectedModels[0]);
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
if (model) { if (model) {
selectedToolIds = (model?.info?.meta?.toolIds ?? []).filter((id) => selectedToolIds = (model?.info?.meta?.toolIds ?? []).filter((id) =>
$tools.find((t) => t.id === id) $tools.find((t) => t.id === id)
@ -836,6 +841,7 @@
content: m.content, content: m.content,
info: m.info ? m.info : undefined, info: m.info ? m.info : undefined,
timestamp: m.timestamp, timestamp: m.timestamp,
...(m.usage ? { usage: m.usage } : {}),
...(m.sources ? { sources: m.sources } : {}) ...(m.sources ? { sources: m.sources } : {})
})), })),
model_item: $models.find((m) => m.id === modelId), model_item: $models.find((m) => m.id === modelId),
@ -1273,7 +1279,9 @@
const chatInputElement = document.getElementById('chat-input'); const chatInputElement = document.getElementById('chat-input');
if (chatInputElement) { if (chatInputElement) {
await tick();
chatInputElement.style.height = ''; chatInputElement.style.height = '';
chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 320) + 'px';
} }
const _files = JSON.parse(JSON.stringify(files)); const _files = JSON.parse(JSON.stringify(files));
@ -1488,7 +1496,10 @@
params?.system ?? $settings?.system ?? '', params?.system ?? $settings?.system ?? '',
$user.name, $user.name,
$settings?.userLocation $settings?.userLocation
? await getAndUpdateUserLocation(localStorage.token) ? await getAndUpdateUserLocation(localStorage.token).catch((err) => {
console.error(err);
return undefined;
})
: undefined : undefined
)}${ )}${
(responseMessage?.userContext ?? null) (responseMessage?.userContext ?? null)
@ -1573,7 +1584,12 @@
variables: { variables: {
...getPromptVariables( ...getPromptVariables(
$user.name, $user.name,
$settings?.userLocation ? await getAndUpdateUserLocation(localStorage.token) : undefined $settings?.userLocation
? await getAndUpdateUserLocation(localStorage.token).catch((err) => {
console.error(err);
return undefined;
})
: undefined
) )
}, },
model_item: $models.find((m) => m.id === model.id), model_item: $models.find((m) => m.id === model.id),
@ -1965,6 +1981,7 @@
bind:autoScroll bind:autoScroll
bind:prompt bind:prompt
{selectedModels} {selectedModels}
{atSelectedModel}
{sendPrompt} {sendPrompt}
{showMessage} {showMessage}
{submitMessage} {submitMessage}

View file

@ -16,6 +16,7 @@
export let modelIds = []; export let modelIds = [];
export let models = []; export let models = [];
export let atSelectedModel;
export let submitPrompt; export let submitPrompt;
@ -126,7 +127,8 @@
<div class=" w-full font-primary" in:fade={{ duration: 200, delay: 300 }}> <div class=" w-full font-primary" in:fade={{ duration: 200, delay: 300 }}>
<Suggestions <Suggestions
className="grid grid-cols-2" className="grid grid-cols-2"
suggestionPrompts={models[selectedModelIdx]?.info?.meta?.suggestion_prompts ?? suggestionPrompts={atSelectedModel?.info?.meta?.suggestion_prompts ??
models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
$config?.default_prompt_suggestions ?? $config?.default_prompt_suggestions ??
[]} []}
on:select={(e) => { on:select={(e) => {

View file

@ -2,6 +2,7 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker'; import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
import { pickAndDownloadFile } from '$lib/utils/onedrive-file-picker';
import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte'; import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -173,22 +174,6 @@
} }
files = [...files, fileItem]; files = [...files, fileItem];
// Check if the file is an audio file and transcribe/convert it to text file
if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
console.log(res);
const blob = new Blob([res.text], { type: 'text/plain' });
file = blobToFile(blob, `${file.name}.txt`);
fileItem.name = file.name;
fileItem.size = file.size;
}
}
try { try {
// During the file upload, file content is automatically extracted. // During the file upload, file content is automatically extracted.
@ -249,7 +234,9 @@
return; return;
} }
if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) { if (
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
) {
if (visionCapableModels.length === 0) { if (visionCapableModels.length === 0) {
toast.error($i18n.t('Selected model(s) do not support image inputs')); toast.error($i18n.t('Selected model(s) do not support image inputs'));
return; return;
@ -428,7 +415,7 @@
</div> </div>
{/if} {/if}
{#if webSearchEnabled || ($settings?.webSearch ?? false) === 'always'} {#if webSearchEnabled || ($config?.features?.enable_web_search && ($settings?.webSearch ?? false)) === 'always'}
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2.5 text-sm dark:text-gray-500"> <div class="flex items-center gap-2.5 text-sm dark:text-gray-500">
<div class="pl-1"> <div class="pl-1">
@ -825,7 +812,11 @@
} }
// Submit the prompt when Enter key is pressed // Submit the prompt when Enter key is pressed
if (prompt !== '' && e.keyCode === 13 && !e.shiftKey) { if (
(prompt !== '' || files.length > 0) &&
e.keyCode === 13 &&
!e.shiftKey
) {
dispatch('submit', prompt); dispatch('submit', prompt);
} }
} }
@ -904,7 +895,11 @@
} }
// Submit the prompt when Enter key is pressed // Submit the prompt when Enter key is pressed
if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) { if (
(prompt !== '' || files.length > 0) &&
e.key === 'Enter' &&
!e.shiftKey
) {
dispatch('submit', prompt); dispatch('submit', prompt);
} }
} }
@ -1106,6 +1101,21 @@
); );
} }
}} }}
uploadOneDriveHandler={async () => {
try {
const fileData = await pickAndDownloadFile();
if (fileData) {
const file = new File([fileData.blob], fileData.name, {
type: fileData.blob.type || 'application/octet-stream'
});
await uploadFileHandler(file);
} else {
console.log('No file was selected from OneDrive');
}
} catch (error) {
console.error('OneDrive Error:', error);
}
}}
onClose={async () => { onClose={async () => {
await tick(); await tick();
@ -1283,6 +1293,8 @@
stream = null; stream = null;
if ($settings.audio?.tts?.engine === 'browser-kokoro') {
// If the user has not initialized the TTS worker, initialize it
if (!$TTSWorker) { if (!$TTSWorker) {
await TTSWorker.set( await TTSWorker.set(
new KokoroWorker({ new KokoroWorker({
@ -1292,6 +1304,7 @@
await $TTSWorker.init(); await $TTSWorker.init();
} }
}
showCallOverlay.set(true); showCallOverlay.set(true);
showControls.set(true); showControls.set(true);

View file

@ -74,7 +74,13 @@
} }
if (command.content.includes('{{USER_LOCATION}}')) { if (command.content.includes('{{USER_LOCATION}}')) {
const location = await getUserPosition(); let location;
try {
location = await getUserPosition();
} catch (error) {
toast.error($i18n.t('Location access not allowed'));
location = 'LOCATION_UNKNOWN';
}
text = text.replaceAll('{{USER_LOCATION}}', String(location)); text = text.replaceAll('{{USER_LOCATION}}', String(location));
} }

View file

@ -5,6 +5,7 @@
import { config, user, tools as _tools, mobile } from '$lib/stores'; import { config, user, tools as _tools, mobile } from '$lib/stores';
import { createPicker } from '$lib/utils/google-drive-picker'; import { createPicker } from '$lib/utils/google-drive-picker';
import { getTools } from '$lib/apis/tools'; import { getTools } from '$lib/apis/tools';
import Dropdown from '$lib/components/common/Dropdown.svelte'; import Dropdown from '$lib/components/common/Dropdown.svelte';
@ -24,6 +25,7 @@
export let inputFilesHandler: Function; export let inputFilesHandler: Function;
export let uploadGoogleDriveHandler: Function; export let uploadGoogleDriveHandler: Function;
export let uploadOneDriveHandler: Function;
export let selectedToolIds: string[] = []; export let selectedToolIds: string[] = [];
@ -225,6 +227,97 @@
<div class="line-clamp-1">{$i18n.t('Google Drive')}</div> <div class="line-clamp-1">{$i18n.t('Google Drive')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
{/if} {/if}
{#if $config?.features?.enable_onedrive_integration}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler();
}}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" class="w-5 h-5" fill="none">
<mask
id="mask0_87_7796"
style="mask-type:alpha"
maskUnits="userSpaceOnUse"
x="0"
y="6"
width="32"
height="20"
>
<path
d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
fill="#C4C4C4"
/>
</mask>
<g mask="url(#mask0_87_7796)">
<path
d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
fill="url(#paint0_linear_87_7796)"
/>
<path
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
fill="url(#paint1_linear_87_7796)"
/>
<path
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
fill="url(#paint2_linear_87_7796)"
/>
<path
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
fill="url(#paint3_linear_87_7796)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_87_7796"
x1="4.42591"
y1="24.6668"
x2="27.2309"
y2="23.2764"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#2086B8" />
<stop offset="1" stop-color="#46D3F6" />
</linearGradient>
<linearGradient
id="paint1_linear_87_7796"
x1="23.8302"
y1="19.6668"
x2="30.2108"
y2="15.2082"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#1694DB" />
<stop offset="1" stop-color="#62C3FE" />
</linearGradient>
<linearGradient
id="paint2_linear_87_7796"
x1="8.51037"
y1="7.33333"
x2="23.3335"
y2="15.9348"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0D3D78" />
<stop offset="1" stop-color="#063B83" />
</linearGradient>
<linearGradient
id="paint3_linear_87_7796"
x1="-0.340429"
y1="19.9998"
x2="14.5634"
y2="14.4649"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#16589B" />
<stop offset="1" stop-color="#1464B7" />
</linearGradient>
</defs>
</svg>
<div class="line-clamp-1">{$i18n.t('OneDrive')}</div>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content> </DropdownMenu.Content>
</div> </div>
</Dropdown> </Dropdown>

View file

@ -1,6 +1,14 @@
<script lang="ts"> <script lang="ts">
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { chats, config, settings, user as _user, mobile, currentChatPage } from '$lib/stores'; import {
chats,
config,
settings,
user as _user,
mobile,
currentChatPage,
temporaryChatEnabled
} from '$lib/stores';
import { tick, getContext, onMount, createEventDispatcher } from 'svelte'; import { tick, getContext, onMount, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -24,6 +32,7 @@
export let prompt; export let prompt;
export let history = {}; export let history = {};
export let selectedModels; export let selectedModels;
export let atSelectedModel;
let messages = []; let messages = [];
@ -85,6 +94,7 @@
}; };
const updateChat = async () => { const updateChat = async () => {
if (!$temporaryChatEnabled) {
history = history; history = history;
await tick(); await tick();
await updateChatById(localStorage.token, chatId, { await updateChatById(localStorage.token, chatId, {
@ -94,6 +104,7 @@
currentChatPage.set(1); currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage)); await chats.set(await getChatList(localStorage.token, $currentChatPage));
}
}; };
const showPreviousMessage = async (message) => { const showPreviousMessage = async (message) => {
@ -339,6 +350,7 @@
{#if Object.keys(history?.messages ?? {}).length == 0} {#if Object.keys(history?.messages ?? {}).length == 0}
<ChatPlaceholder <ChatPlaceholder
modelIds={selectedModels} modelIds={selectedModels}
{atSelectedModel}
submitPrompt={async (p) => { submitPrompt={async (p) => {
let text = p; let text = p;

View file

@ -7,6 +7,7 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let id = '';
export let sources = []; export let sources = [];
let citations = []; let citations = [];
@ -42,6 +43,7 @@
} }
$: { $: {
console.log('sources', sources);
citations = sources.reduce((acc, source) => { citations = sources.reduce((acc, source) => {
if (Object.keys(source).length === 0) { if (Object.keys(source).length === 0) {
return acc; return acc;
@ -52,7 +54,7 @@
const distance = source.distances?.[index]; const distance = source.distances?.[index];
// Within the same citation there could be multiple documents // Within the same citation there could be multiple documents
const id = metadata?.source ?? 'N/A'; const id = metadata?.source ?? source?.source?.id ?? 'N/A';
let _source = source?.source; let _source = source?.source;
if (metadata?.name) { if (metadata?.name) {
@ -100,7 +102,7 @@
<div class="flex text-xs font-medium flex-wrap"> <div class="flex text-xs font-medium flex-wrap">
{#each citations as citation, idx} {#each citations as citation, idx}
<button <button
id={`source-${idx}`} id={`source-${id}-${idx}`}
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-white dark:bg-gray-900 rounded-xl max-w-96" class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-white dark:bg-gray-900 rounded-xl max-w-96"
on:click={() => { on:click={() => {
showCitationModal = true; showCitationModal = true;
@ -179,7 +181,7 @@
<div class="flex text-xs font-medium flex-wrap"> <div class="flex text-xs font-medium flex-wrap">
{#each citations as citation, idx} {#each citations as citation, idx}
<button <button
id={`source-${idx}`} id={`source-${id}-${idx}`}
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96" class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
on:click={() => { on:click={() => {
showCitationModal = true; showCitationModal = true;

View file

@ -1,18 +1,9 @@
<script lang="ts"> <script lang="ts">
import hljs from 'highlight.js';
import { loadPyodide } from 'pyodide';
import mermaid from 'mermaid'; import mermaid from 'mermaid';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { import { getContext, onMount, tick, onDestroy } from 'svelte';
getContext,
getAllContexts,
onMount,
tick,
createEventDispatcher,
onDestroy
} from 'svelte';
import { copyToClipboard } from '$lib/utils'; import { copyToClipboard } from '$lib/utils';
import 'highlight.js/styles/github-dark.min.css'; import 'highlight.js/styles/github-dark.min.css';
@ -20,12 +11,17 @@
import PyodideWorker from '$lib/workers/pyodide.worker?worker'; import PyodideWorker from '$lib/workers/pyodide.worker?worker';
import CodeEditor from '$lib/components/common/CodeEditor.svelte'; import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import SvgPanZoom from '$lib/components/common/SVGPanZoom.svelte'; import SvgPanZoom from '$lib/components/common/SVGPanZoom.svelte';
import { config } from '$lib/stores';
import { executeCode } from '$lib/apis/utils';
import { toast } from 'svelte-sonner';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let id = ''; export let id = '';
export let onSave = (e) => {};
export let onCode = (e) => {};
export let save = false; export let save = false;
export let run = true; export let run = true;
@ -68,7 +64,7 @@
saved = true; saved = true;
code = _code; code = _code;
dispatch('save', code); onSave(code);
setTimeout(() => { setTimeout(() => {
saved = false; saved = false;
@ -120,16 +116,87 @@
}; };
const executePython = async (code) => { const executePython = async (code) => {
executePythonAsWorker(code);
};
const executePythonAsWorker = async (code) => {
result = null; result = null;
stdout = null; stdout = null;
stderr = null; stderr = null;
executing = true; executing = true;
if ($config?.code?.engine === 'jupyter') {
const output = await executeCode(localStorage.token, code).catch((error) => {
toast.error(`${error}`);
return null;
});
if (output) {
if (output['stdout']) {
stdout = output['stdout'];
const stdoutLines = stdout.split('\n');
for (const [idx, line] of stdoutLines.entries()) {
if (line.startsWith('data:image/png;base64')) {
if (files) {
files.push({
type: 'image/png',
data: line
});
} else {
files = [
{
type: 'image/png',
data: line
}
];
}
if (stdout.startsWith(`${line}\n`)) {
stdout = stdout.replace(`${line}\n`, ``);
} else if (stdout.startsWith(`${line}`)) {
stdout = stdout.replace(`${line}`, ``);
}
}
}
}
if (output['result']) {
result = output['result'];
const resultLines = result.split('\n');
for (const [idx, line] of resultLines.entries()) {
if (line.startsWith('data:image/png;base64')) {
if (files) {
files.push({
type: 'image/png',
data: line
});
} else {
files = [
{
type: 'image/png',
data: line
}
];
}
if (result.startsWith(`${line}\n`)) {
result = result.replace(`${line}\n`, ``);
} else if (result.startsWith(`${line}`)) {
result = result.replace(`${line}`, ``);
}
}
}
}
output['stderr'] && (stderr = output['stderr']);
}
executing = false;
} else {
executePythonAsWorker(code);
}
};
const executePythonAsWorker = async (code) => {
let packages = [ let packages = [
code.includes('requests') ? 'requests' : null, code.includes('requests') ? 'requests' : null,
code.includes('bs4') ? 'beautifulsoup4' : null, code.includes('bs4') ? 'beautifulsoup4' : null,
@ -189,7 +256,40 @@
]; ];
} }
if (stdout.startsWith(`${line}\n`)) {
stdout = stdout.replace(`${line}\n`, ``); stdout = stdout.replace(`${line}\n`, ``);
} else if (stdout.startsWith(`${line}`)) {
stdout = stdout.replace(`${line}`, ``);
}
}
}
}
if (data['result']) {
result = data['result'];
const resultLines = result.split('\n');
for (const [idx, line] of resultLines.entries()) {
if (line.startsWith('data:image/png;base64')) {
if (files) {
files.push({
type: 'image/png',
data: line
});
} else {
files = [
{
type: 'image/png',
data: line
}
];
}
if (result.startsWith(`${line}\n`)) {
result = result.replace(`${line}\n`, ``);
} else if (result.startsWith(`${line}`)) {
result = result.replace(`${line}`, ``);
}
} }
} }
} }
@ -237,7 +337,7 @@
render(); render();
} }
$: dispatch('code', { lang, code }); $: onCode({ lang, code });
$: if (attributes) { $: if (attributes) {
onAttributesUpdate(); onAttributesUpdate();
@ -273,7 +373,7 @@
console.log('codeblock', lang, code); console.log('codeblock', lang, code);
if (lang) { if (lang) {
dispatch('code', { lang, code }); onCode({ lang, code });
} }
if (document.documentElement.classList.contains('dark')) { if (document.documentElement.classList.contains('dark')) {
mermaid.initialize({ mermaid.initialize({
@ -361,11 +461,11 @@
value={code} value={code}
{id} {id}
{lang} {lang}
on:save={() => { onSave={() => {
saveCode(); saveCode();
}} }}
on:change={(e) => { onChange={(value) => {
_code = e.detail.value; _code = value;
}} }}
/> />
</div> </div>
@ -375,7 +475,7 @@
class="bg-gray-50 dark:bg-[#202123] dark:text-white max-w-full overflow-x-auto scrollbar-hidden" class="bg-gray-50 dark:bg-[#202123] dark:text-white max-w-full overflow-x-auto scrollbar-hidden"
/> />
{#if executing || stdout || stderr || result} {#if executing || stdout || stderr || result || files}
<div <div
class="bg-gray-50 dark:bg-[#202123] dark:text-white rounded-b-lg! py-4 px-4 flex flex-col gap-2" class="bg-gray-50 dark:bg-[#202123] dark:text-white rounded-b-lg! py-4 px-4 flex flex-col gap-2"
> >
@ -388,7 +488,13 @@
{#if stdout || stderr} {#if stdout || stderr}
<div class=" "> <div class=" ">
<div class=" text-gray-500 text-xs mb-1">STDOUT/STDERR</div> <div class=" text-gray-500 text-xs mb-1">STDOUT/STDERR</div>
<div class="text-sm">{stdout || stderr}</div> <div
class="text-sm {stdout?.split('\n')?.length > 100
? `max-h-96`
: ''} overflow-y-auto"
>
{stdout || stderr}
</div>
</div> </div>
{/if} {/if}
{#if result || files} {#if result || files}
@ -401,7 +507,7 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each files as file} {#each files as file}
{#if file.type.startsWith('image')} {#if file.type.startsWith('image')}
<img src={file.data} alt="Output" /> <img src={file.data} alt="Output" class=" w-full max-w-[36rem]" />
{/if} {/if}
{/each} {/each}
</div> </div>

View file

@ -120,6 +120,11 @@
sourceIds={(sources ?? []).reduce((acc, s) => { sourceIds={(sources ?? []).reduce((acc, s) => {
let ids = []; let ids = [];
s.document.forEach((document, index) => { s.document.forEach((document, index) => {
if (model?.info?.meta?.capabilities?.citations == false) {
ids.push('N/A');
return ids;
}
const metadata = s.metadata?.[index]; const metadata = s.metadata?.[index];
const id = metadata?.source ?? 'N/A'; const id = metadata?.source ?? 'N/A';

View file

@ -29,7 +29,7 @@
{:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)} {:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)}
{@html `${token.text}`} {@html `${token.text}`}
{:else if token.text.includes(`<source_id`)} {:else if token.text.includes(`<source_id`)}
<Source {token} onClick={onSourceClick} /> <Source {id} {token} onClick={onSourceClick} />
{:else} {:else}
{token.text} {token.text}
{/if} {/if}

View file

@ -76,7 +76,7 @@
{#if token.type === 'hr'} {#if token.type === 'hr'}
<hr class=" border-gray-100 dark:border-gray-850" /> <hr class=" border-gray-100 dark:border-gray-850" />
{:else if token.type === 'heading'} {:else if token.type === 'heading'}
<svelte:element this={headerComponent(token.depth)}> <svelte:element this={headerComponent(token.depth)} dir="auto">
<MarkdownInlineTokens id={`${id}-${tokenIdx}-h`} tokens={token.tokens} {onSourceClick} /> <MarkdownInlineTokens id={`${id}-${tokenIdx}-h`} tokens={token.tokens} {onSourceClick} />
</svelte:element> </svelte:element>
{:else if token.type === 'code'} {:else if token.type === 'code'}
@ -88,14 +88,14 @@
code={token?.text ?? ''} code={token?.text ?? ''}
{attributes} {attributes}
{save} {save}
on:code={(e) => { onCode={(value) => {
dispatch('code', e.detail); dispatch('code', value);
}} }}
on:save={(e) => { onSave={(e) => {
dispatch('update', { dispatch('update', {
raw: token.raw, raw: token.raw,
oldContent: token.text, oldContent: token.text,
newContent: e.detail newContent: value
}); });
}} }}
/> />
@ -169,14 +169,14 @@
</div> </div>
</div> </div>
{:else if token.type === 'blockquote'} {:else if token.type === 'blockquote'}
<blockquote> <blockquote dir="auto">
<svelte:self id={`${id}-${tokenIdx}`} tokens={token.tokens} {onTaskClick} {onSourceClick} /> <svelte:self id={`${id}-${tokenIdx}`} tokens={token.tokens} {onTaskClick} {onSourceClick} />
</blockquote> </blockquote>
{:else if token.type === 'list'} {:else if token.type === 'list'}
{#if token.ordered} {#if token.ordered}
<ol start={token.start || 1}> <ol start={token.start || 1}>
{#each token.items as item, itemIdx} {#each token.items as item, itemIdx}
<li> <li dir="auto" class="text-start">
{#if item?.task} {#if item?.task}
<input <input
class=" translate-y-[1px] -translate-x-1" class=" translate-y-[1px] -translate-x-1"
@ -208,7 +208,7 @@
{:else} {:else}
<ul> <ul>
{#each token.items as item, itemIdx} {#each token.items as item, itemIdx}
<li> <li dir="auto" class="text-start">
{#if item?.task} {#if item?.task}
<input <input
class=" translate-y-[1px] -translate-x-1" class=" translate-y-[1px] -translate-x-1"
@ -239,7 +239,12 @@
</ul> </ul>
{/if} {/if}
{:else if token.type === 'details'} {:else if token.type === 'details'}
<Collapsible title={token.summary} attributes={token?.attributes} className="w-full space-y-1"> <Collapsible
title={token.summary}
attributes={token?.attributes}
className="w-full space-y-1"
dir="auto"
>
<div class=" mb-1.5" slot="content"> <div class=" mb-1.5" slot="content">
<svelte:self <svelte:self
id={`${id}-${tokenIdx}-d`} id={`${id}-${tokenIdx}-d`}
@ -268,7 +273,7 @@
onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';" onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';"
></iframe> ></iframe>
{:else if token.type === 'paragraph'} {:else if token.type === 'paragraph'}
<p> <p dir="auto">
<MarkdownInlineTokens <MarkdownInlineTokens
id={`${id}-${tokenIdx}-p`} id={`${id}-${tokenIdx}-p`}
tokens={token.tokens ?? []} tokens={token.tokens ?? []}
@ -277,7 +282,7 @@
</p> </p>
{:else if token.type === 'text'} {:else if token.type === 'text'}
{#if top} {#if top}
<p> <p dir="auto">
{#if token.tokens} {#if token.tokens}
<MarkdownInlineTokens id={`${id}-${tokenIdx}-t`} tokens={token.tokens} {onSourceClick} /> <MarkdownInlineTokens id={`${id}-${tokenIdx}-t`} tokens={token.tokens} {onSourceClick} />
{:else} {:else}

View file

@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
export let id;
export let token; export let token;
export let onClick: Function = () => {}; export let onClick: Function = () => {};
let attributes: Record<string, string> = {}; let attributes: Record<string, string | undefined> = {};
function extractAttributes(input: string): Record<string, string> { function extractAttributes(input: string): Record<string, string> {
const regex = /(\w+)="([^"]*)"/g; const regex = /(\w+)="([^"]*)"/g;
@ -35,13 +36,15 @@
$: attributes = extractAttributes(token.text); $: attributes = extractAttributes(token.text);
</script> </script>
<button {#if attributes.title !== 'N/A'}
<button
class="text-xs font-medium w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/60 dark:hover:text-white bg-gray-50 text-black/60 hover:text-black transition rounded-lg" class="text-xs font-medium w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/60 dark:hover:text-white bg-gray-50 text-black/60 hover:text-black transition rounded-lg"
on:click={() => { on:click={() => {
onClick(attributes.data); onClick(id, attributes.data);
}} }}
> >
<span class="line-clamp-1"> <span class="line-clamp-1">
{formattedTitle(attributes.title)} {attributes.title ? formattedTitle(attributes.title) : ''}
</span> </span>
</button> </button>
{/if}

View file

@ -37,6 +37,9 @@
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import WebSearchResults from './ResponseMessage/WebSearchResults.svelte'; import WebSearchResults from './ResponseMessage/WebSearchResults.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte'; import Sparkles from '$lib/components/icons/Sparkles.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Error from './Error.svelte'; import Error from './Error.svelte';
import Citations from './Citations.svelte'; import Citations from './Citations.svelte';
import CodeExecutions from './CodeExecutions.svelte'; import CodeExecutions from './CodeExecutions.svelte';
@ -126,6 +129,9 @@
export let isLastMessage = true; export let isLastMessage = true;
export let readOnly = false; export let readOnly = false;
let buttonsContainerElement: HTMLDivElement;
let showDeleteConfirm = false;
let model = null; let model = null;
$: model = $models.find((m) => m.id === message.model); $: model = $models.find((m) => m.id === message.model);
@ -513,9 +519,29 @@
// console.log('ResponseMessage mounted'); // console.log('ResponseMessage mounted');
await tick(); await tick();
if (buttonsContainerElement) {
console.log(buttonsContainerElement);
buttonsContainerElement.addEventListener('wheel', function (event) {
// console.log(event.deltaY);
event.preventDefault();
if (event.deltaY !== 0) {
// Adjust horizontal scroll position based on vertical scroll
buttonsContainerElement.scrollLeft += event.deltaY;
}
});
}
}); });
</script> </script>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete message?')}
on:confirm={() => {
deleteMessageHandler();
}}
/>
{#key message.id} {#key message.id}
<div <div
class=" flex w-full message-{message.id}" class=" flex w-full message-{message.id}"
@ -719,9 +745,9 @@
onTaskClick={async (e) => { onTaskClick={async (e) => {
console.log(e); console.log(e);
}} }}
onSourceClick={async (e) => { onSourceClick={async (id, idx) => {
console.log(e); console.log(id, idx);
let sourceButton = document.getElementById(`source-${e}`); let sourceButton = document.getElementById(`source-${message.id}-${idx}`);
const sourcesCollapsible = document.getElementById(`collapsible-sources`); const sourcesCollapsible = document.getElementById(`collapsible-sources`);
if (sourceButton) { if (sourceButton) {
@ -740,7 +766,7 @@
}); });
// Try clicking the source button again // Try clicking the source button again
sourceButton = document.getElementById(`source-${e}`); sourceButton = document.getElementById(`source-${message.id}-${idx}`);
sourceButton && sourceButton.click(); sourceButton && sourceButton.click();
} }
}} }}
@ -777,7 +803,7 @@
{/if} {/if}
{#if (message?.sources || message?.citations) && (model?.info?.meta?.capabilities?.citations ?? true)} {#if (message?.sources || message?.citations) && (model?.info?.meta?.capabilities?.citations ?? true)}
<Citations sources={message?.sources ?? message?.citations} /> <Citations id={message?.id} sources={message?.sources ?? message?.citations} />
{/if} {/if}
{#if message.code_executions} {#if message.code_executions}
@ -789,10 +815,11 @@
</div> </div>
{#if !edit} {#if !edit}
{#if message.done || siblings.length > 1}
<div <div
class=" flex justify-start overflow-x-auto buttons text-gray-600 dark:text-gray-500 mt-0.5" bind:this={buttonsContainerElement}
class="flex justify-start overflow-x-auto buttons text-gray-600 dark:text-gray-500 mt-0.5"
> >
{#if message.done || siblings.length > 1}
{#if siblings.length > 1} {#if siblings.length > 1}
<div class="flex self-center min-w-fit" dir="ltr"> <div class="flex self-center min-w-fit" dir="ltr">
<button <button
@ -1247,7 +1274,7 @@
? 'visible' ? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button" : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
on:click={() => { on:click={() => {
deleteMessageHandler(); showDeleteConfirm = true;
}} }}
> >
<svg <svg
@ -1300,8 +1327,8 @@
{/if} {/if}
{/if} {/if}
{/if} {/if}
</div>
{/if} {/if}
</div>
{#if message.done && showRateComment} {#if message.done && showRateComment}
<RateComment <RateComment

View file

@ -5,22 +5,32 @@
<div class="w-full mt-2 mb-2"> <div class="w-full mt-2 mb-2">
<div class="animate-pulse flex w-full"> <div class="animate-pulse flex w-full">
<div class="{size === 'md' ? 'space-y-2' : 'space-y-1.5'} w-full"> <div class="{size === 'md' ? 'space-y-2' : 'space-y-1.5'} w-full">
<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm mr-14" /> <div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm mr-14"
/>
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<div <div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-2" class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-2"
/> />
<div <div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1" class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1"
/> />
</div> </div>
<div class="grid grid-cols-4 gap-4"> <div class="grid grid-cols-4 gap-4">
<div <div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1" class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1"
/> />
<div <div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-2" class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-2"
/> />
<div <div
class="{size === 'md' class="{size === 'md'

Some files were not shown because too many files have changed in this diff Show more