Merge pull request #11728 from open-webui/dev

0.6
This commit is contained in:
Timothy Jaeryang Baek 2025-03-31 18:47:18 -07:00 committed by GitHub
commit 04799f1f95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
186 changed files with 11166 additions and 2816 deletions

View file

@ -9,9 +9,9 @@
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description. - [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
- [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources? - [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources?
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation? - [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
- [ ] **Testing:** Have you written and run sufficient tests for validating the changes? - [ ] **Testing:** Have you written and run sufficient tests to validate the changes?
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards? - [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
- [ ] **Prefix:** To cleary categorize this pull request, prefix the pull request title, using one of the following: - [ ] **Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility - **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies - **build**: Changes that affect the build system or external dependencies
- **ci**: Changes to our continuous integration processes or workflows - **ci**: Changes to our continuous integration processes or workflows
@ -22,7 +22,7 @@
- **i18n**: Internationalization or localization changes - **i18n**: Internationalization or localization changes
- **perf**: Performance improvement - **perf**: Performance improvement
- **refactor**: Code restructuring for better maintainability, readability, or scalability - **refactor**: Code restructuring for better maintainability, readability, or scalability
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc.) - **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.)
- **test**: Adding missing tests or correcting existing tests - **test**: Adding missing tests or correcting existing tests
- **WIP**: Work in progress, a temporary label for incomplete or ongoing work - **WIP**: Work in progress, a temporary label for incomplete or ongoing work

View file

@ -5,6 +5,55 @@ 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.6.0] - 2025-03-31
### Added
- 🧩 **External Tool Server Support via OpenAPI**: Connect Open WebUI to any OpenAPI-compatible REST server instantly—offering immediate integration with thousands of developer tools, SDKs, and SaaS systems for powerful extensibility. Learn more: https://github.com/open-webui/openapi-servers
- 🛠️ **MCP Server Support via MCPO**: You can now convert and expose your internal MCP tools as interoperable OpenAPI HTTP servers within Open WebUI for seamless, plug-n-play AI toolchain creation. Learn more: https://github.com/open-webui/mcpo
- 📨 **/messages Chat API Endpoint Support**: For power users building external AI systems, new endpoints allow precise control of messages asynchronously—feed long-running external responses into Open WebUI chats without coupling with the frontend.
- 📝 **Client-Side PDF Generation**: PDF exports are now generated fully client-side for drastically improved output quality—perfect for saving conversations or documents.
- 💼 **Enforced Temporary Chats Mode**: Admins can now enforce temporary chat sessions by default to align with stringent data retention and compliance requirements.
- 🌍 **Public Resource Sharing Permission Controls**: Fine-grained user group permissions now allow enabling/disabling public sharing for models, knowledge, prompts, and tools—ideal for privacy, team control, and internal deployments.
- 📦 **Custom pip Options for Tools/Functions**: You can now specify custom pip installation options with "PIP_OPTIONS", "PIP_PACKAGE_INDEX_OPTIONS" environment variables—improving compatibility, support for private indexes, and better control over Python environments.
- 🔢 **Editable Message Counter**: You can now double-click the message count number and jump straight to editing the index—quickly navigate complex chats or regenerate specific messages precisely.
- 🧠 **Embedding Prefix Support Added**: Add custom prefixes to your embeddings for instruct-style tokens, enabling stronger model alignment and more consistent RAG performance.
- 🙈 **Ability to Hide Base Models**: Optionally hide base models from the UI, helping users streamline model visibility and limit access to only usable endpoints..
- 📚 **Docling Content Extraction Support**: Open WebUI now supports Docling as a content extraction engine, enabling smarter and more accurate parsing of complex file formats—ideal for advanced document understanding and Retrieval-Augmented Generation (RAG) workflows.
- 🗃️ **Redis Sentinel Support Added**: Enhance deployment redundancy with support for Redis Sentinel for highly available, failover-safe Redis-based caching or pub/sub.
- 📚 **JSON Schema Format for Ollama**: Added support for defining the format using JSON schema in Ollama-compatible models, improving flexibility and validation of model outputs.
- 🔍 **Chat Sidebar Search "Clear” Button**: Quickly clear search filters in chat sidebar using the new ✖️ button—streamline your chat navigation with one click.
- 🗂️ **Auto-Focus + Enter Submit for Folder Name**: When creating a new folder, the system automatically enters rename mode with name preselected—simplifying your org workflow.
- 🧱 **Markdown Alerts Rendering**: Blockquotes with syntax hinting (e.g. ⚠️, , ✅) now render styled Markdown alert banners, making messages and documentation more visually structured.
- 🔁 **Hybrid Search Runs in Parallel Now**: Hybrid (BM25 + embedding) search components now run in parallel—dramatically reducing response times and speeding up document retrieval.
- 📋 **Cleaner UI for Tool Call Display**: Optimized the visual layout of called tools inside chat messages for better clarity and reduced visual clutter.
- 🧪 **Playwright Timeout Now Configurable**: Default timeout for Playwright processes is now shorter and adjustable via environment variables—making web scraping more robust and tunable to environments.
- 📈 **OpenTelemetry Support for Observability**: Open WebUI now integrates with OpenTelemetry, allowing you to connect with tools like Grafana, Jaeger, or Prometheus for detailed performance insights and real-time visibility—entirely opt-in and fully self-hosted. Even if enabled, no data is ever sent to us, ensuring your privacy and ownership over all telemetry data.
- 🛠 **General UI Enhancements & UX Polish**: Numerous refinements across sidebar, code blocks, modal interactions, button alignment, scrollbar visibility, and folder behavior improve overall fluidity and usability of the interface.
- 🧱 **General Backend Refactoring**: Numerous backend components have been refactored to improve stability, maintainability, and performance—ensuring a more consistent and reliable system across all features.
- 🌍 **Internationalization Language Support Updates**: Added Estonian and Galician languages, improved Spanish (fully revised), Traditional Chinese, Simplified Chinese, Turkish, Catalan, Ukrainian, and German for a more localized and inclusive interface.
### Fixed
- 🧑‍💻 **Firefox Input Height Bug**: Text input in Firefox now maintains proper height, ensuring message boxes look consistent and behave predictably.
- 🧾 **Tika Blank Line Bug**: PDFs processed with Apache Tika 3.1.0.0 no longer introduce excessive blank lines—improving RAG output quality and visual cleanliness.
- 🧪 **CSV Loader Encoding Issues**: CSV files with unknown encodings now automatically detect character sets, resolving import errors in non-UTF-8 datasets.
- ✅ **LDAP Auth Config Fix**: Path to certificate file is now optional for LDAP setups, fixing authentication trouble for users without preconfigured cert paths.
- 📥 **File Deletion in Bypass Mode**: Resolved issue where files couldnt be deleted from knowledge when “bypass embedding” mode was enabled.
- 🧩 **Hybrid Search Result Sorting & Deduplication Fixed**: Fixed citation and sorting issues in RAG hybrid and reranker modes, ensuring retrieved documents are shown in correct order per score.
- 🧷 **Model Export/Import Broken for a Single Model**: Fixed bug where individual models couldnt be exported or re-imported, restoring full portability.
- 📫 **Auth Redirect Fix**: Logged-in users are now routed properly without unnecessary login prompts when already authenticated.
### Changed
- 🧠 **Prompt Autocompletion Disabled By Default**: Autocomplete suggestions while typing are now disabled unless explicitly re-enabled in user preferences—reduces distractions while composing prompts for advanced users.
- 🧾 **Normalize Citation Numbering**: Source citations now properly begin from "1" instead of "0"—improving consistency and professional presentation in AI outputs.
- 📚 **Improved Error Handling from Pipelines**: Pipelines now show the actual returned error message from failed tasks rather than generic "Connection closed"—making debugging far more user-friendly.
### Removed
- 🧾 **ENABLE_AUDIT_LOGS Setting Removed**: Deprecated setting “ENABLE_AUDIT_LOGS” has been fully removed—now controlled via “AUDIT_LOG_LEVEL” instead.
## [0.5.20] - 2025-03-05 ## [0.5.20] - 2025-03-05
### Added ### Added

View file

@ -132,7 +132,7 @@ RUN if [ "$USE_OLLAMA" = "true" ]; then \
# install python dependencies # install python dependencies
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
RUN pip3 install uv && \ RUN pip3 install --no-cache-dir uv && \
if [ "$USE_CUDA" = "true" ]; then \ if [ "$USE_CUDA" = "true" ]; then \
# If you use CUDA the whisper and embedding model will be downloaded on first use # If you use CUDA the whisper and embedding model will be downloaded on first use
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \

View file

@ -3,6 +3,7 @@ import logging
import os import os
import shutil import shutil
import base64 import base64
import redis
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -17,6 +18,9 @@ from open_webui.env import (
DATA_DIR, DATA_DIR,
DATABASE_URL, DATABASE_URL,
ENV, ENV,
REDIS_URL,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
FRONTEND_BUILD_DIR, FRONTEND_BUILD_DIR,
OFFLINE_MODE, OFFLINE_MODE,
OPEN_WEBUI_DIR, OPEN_WEBUI_DIR,
@ -26,6 +30,7 @@ from open_webui.env import (
log, log,
) )
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.utils.redis import get_redis_connection
class EndpointFilter(logging.Filter): class EndpointFilter(logging.Filter):
@ -248,9 +253,17 @@ class PersistentConfig(Generic[T]):
class AppConfig: class AppConfig:
_state: dict[str, PersistentConfig] _state: dict[str, PersistentConfig]
_redis: Optional[redis.Redis] = None
def __init__(self): def __init__(
self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = []
):
super().__setattr__("_state", {}) super().__setattr__("_state", {})
if redis_url:
super().__setattr__(
"_redis",
get_redis_connection(redis_url, redis_sentinels, decode_responses=True),
)
def __setattr__(self, key, value): def __setattr__(self, key, value):
if isinstance(value, PersistentConfig): if isinstance(value, PersistentConfig):
@ -259,7 +272,31 @@ class AppConfig:
self._state[key].value = value self._state[key].value = value
self._state[key].save() self._state[key].save()
if self._redis:
redis_key = f"open-webui:config:{key}"
self._redis.set(redis_key, json.dumps(self._state[key].value))
def __getattr__(self, key): def __getattr__(self, key):
if key not in self._state:
raise AttributeError(f"Config key '{key}' not found")
# If Redis is available, check for an updated value
if self._redis:
redis_key = f"open-webui:config:{key}"
redis_value = self._redis.get(redis_key)
if redis_value is not None:
try:
decoded_value = json.loads(redis_value)
# Update the in-memory value if different
if self._state[key].value != decoded_value:
self._state[key].value = decoded_value
log.info(f"Updated {key} from Redis: {decoded_value}")
except json.JSONDecodeError:
log.error(f"Invalid JSON format in Redis for {key}: {redis_value}")
return self._state[key].value return self._state[key].value
@ -943,6 +980,35 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true" os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
) )
USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_CHAT_CONTROLS = ( USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true" os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
) )
@ -963,6 +1029,11 @@ USER_PERMISSIONS_CHAT_TEMPORARY = (
os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true" os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true"
) )
USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED = (
os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED", "False").lower()
== "true"
)
USER_PERMISSIONS_FEATURES_WEB_SEARCH = ( USER_PERMISSIONS_FEATURES_WEB_SEARCH = (
os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true" os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true"
) )
@ -985,12 +1056,19 @@ DEFAULT_USER_PERMISSIONS = {
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS, "prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS, "tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
}, },
"sharing": {
"public_models": USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING,
"public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING,
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
},
"chat": { "chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS, "controls": USER_PERMISSIONS_CHAT_CONTROLS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD, "file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE, "delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT, "edit": USER_PERMISSIONS_CHAT_EDIT,
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY, "temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
"temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED,
}, },
"features": { "features": {
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
@ -1055,6 +1133,12 @@ ENABLE_MESSAGE_RATING = PersistentConfig(
os.environ.get("ENABLE_MESSAGE_RATING", "True").lower() == "true", os.environ.get("ENABLE_MESSAGE_RATING", "True").lower() == "true",
) )
ENABLE_USER_WEBHOOKS = PersistentConfig(
"ENABLE_USER_WEBHOOKS",
"ui.enable_user_webhooks",
os.environ.get("ENABLE_USER_WEBHOOKS", "True").lower() == "true",
)
def validate_cors_origins(origins): def validate_cors_origins(origins):
for origin in origins: for origin in origins:
@ -1276,7 +1360,7 @@ Strictly return in JSON format:
ENABLE_AUTOCOMPLETE_GENERATION = PersistentConfig( ENABLE_AUTOCOMPLETE_GENERATION = PersistentConfig(
"ENABLE_AUTOCOMPLETE_GENERATION", "ENABLE_AUTOCOMPLETE_GENERATION",
"task.autocomplete.enable", "task.autocomplete.enable",
os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "True").lower() == "true", os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "False").lower() == "true",
) )
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig( AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig(
@ -1548,8 +1632,10 @@ QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
# OpenSearch # OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200") OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", True) OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", "true").lower() == "true"
OPENSEARCH_CERT_VERIFY = os.environ.get("OPENSEARCH_CERT_VERIFY", False) OPENSEARCH_CERT_VERIFY = (
os.environ.get("OPENSEARCH_CERT_VERIFY", "false").lower() == "true"
)
OPENSEARCH_USERNAME = os.environ.get("OPENSEARCH_USERNAME", None) OPENSEARCH_USERNAME = os.environ.get("OPENSEARCH_USERNAME", None)
OPENSEARCH_PASSWORD = os.environ.get("OPENSEARCH_PASSWORD", None) OPENSEARCH_PASSWORD = os.environ.get("OPENSEARCH_PASSWORD", None)
@ -1623,6 +1709,12 @@ 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
) )
DOCLING_SERVER_URL = PersistentConfig(
"DOCLING_SERVER_URL",
"rag.docling_server_url",
os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
)
DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig( DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig(
"DOCUMENT_INTELLIGENCE_ENDPOINT", "DOCUMENT_INTELLIGENCE_ENDPOINT",
"rag.document_intelligence_endpoint", "rag.document_intelligence_endpoint",
@ -1646,6 +1738,11 @@ BYPASS_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
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"))
) )
RAG_TOP_K_RERANKER = PersistentConfig(
"RAG_TOP_K_RERANKER",
"rag.top_k_reranker",
int(os.environ.get("RAG_TOP_K_RERANKER", "3")),
)
RAG_RELEVANCE_THRESHOLD = PersistentConfig( RAG_RELEVANCE_THRESHOLD = PersistentConfig(
"RAG_RELEVANCE_THRESHOLD", "RAG_RELEVANCE_THRESHOLD",
"rag.relevance_threshold", "rag.relevance_threshold",
@ -1727,6 +1824,14 @@ RAG_EMBEDDING_BATCH_SIZE = PersistentConfig(
), ),
) )
RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None)
RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None)
RAG_EMBEDDING_PREFIX_FIELD_NAME = os.environ.get(
"RAG_EMBEDDING_PREFIX_FIELD_NAME", None
)
RAG_RERANKING_MODEL = PersistentConfig( RAG_RERANKING_MODEL = PersistentConfig(
"RAG_RERANKING_MODEL", "RAG_RERANKING_MODEL",
"rag.reranking_model", "rag.reranking_model",
@ -1950,6 +2055,12 @@ TAVILY_API_KEY = PersistentConfig(
os.getenv("TAVILY_API_KEY", ""), os.getenv("TAVILY_API_KEY", ""),
) )
TAVILY_EXTRACT_DEPTH = PersistentConfig(
"TAVILY_EXTRACT_DEPTH",
"rag.web.search.tavily_extract_depth",
os.getenv("TAVILY_EXTRACT_DEPTH", "basic"),
)
JINA_API_KEY = PersistentConfig( JINA_API_KEY = PersistentConfig(
"JINA_API_KEY", "JINA_API_KEY",
"rag.web.search.jina_api_key", "rag.web.search.jina_api_key",
@ -2036,6 +2147,12 @@ PLAYWRIGHT_WS_URI = PersistentConfig(
os.environ.get("PLAYWRIGHT_WS_URI", None), os.environ.get("PLAYWRIGHT_WS_URI", None),
) )
PLAYWRIGHT_TIMEOUT = PersistentConfig(
"PLAYWRIGHT_TIMEOUT",
"rag.web.loader.engine.playwright.timeout",
int(os.environ.get("PLAYWRIGHT_TIMEOUT", "10")),
)
FIRECRAWL_API_KEY = PersistentConfig( FIRECRAWL_API_KEY = PersistentConfig(
"FIRECRAWL_API_KEY", "FIRECRAWL_API_KEY",
"firecrawl.api_key", "firecrawl.api_key",

View file

@ -105,7 +105,6 @@ for source in log_sources:
log.setLevel(SRC_LOG_LEVELS["CONFIG"]) log.setLevel(SRC_LOG_LEVELS["CONFIG"])
WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI") WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI")
if WEBUI_NAME != "Open WebUI": if WEBUI_NAME != "Open WebUI":
WEBUI_NAME += " (Open WebUI)" WEBUI_NAME += " (Open WebUI)"
@ -130,7 +129,6 @@ else:
except Exception: except Exception:
PACKAGE_DATA = {"version": "0.0.0"} PACKAGE_DATA = {"version": "0.0.0"}
VERSION = PACKAGE_DATA["version"] VERSION = PACKAGE_DATA["version"]
@ -161,7 +159,6 @@ try:
except Exception: except Exception:
changelog_content = (pkgutil.get_data("open_webui", "CHANGELOG.md") or b"").decode() changelog_content = (pkgutil.get_data("open_webui", "CHANGELOG.md") or b"").decode()
# Convert markdown content to HTML # Convert markdown content to HTML
html_content = markdown.markdown(changelog_content) html_content = markdown.markdown(changelog_content)
@ -192,7 +189,6 @@ for version in soup.find_all("h2"):
changelog_json[version_number] = version_data changelog_json[version_number] = version_data
CHANGELOG = changelog_json CHANGELOG = changelog_json
#################################### ####################################
@ -209,7 +205,6 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true" os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
) )
#################################### ####################################
# WEBUI_BUILD_HASH # WEBUI_BUILD_HASH
#################################### ####################################
@ -244,7 +239,6 @@ if FROM_INIT_PY:
DATA_DIR = Path(os.getenv("DATA_DIR", OPEN_WEBUI_DIR / "data")) DATA_DIR = Path(os.getenv("DATA_DIR", OPEN_WEBUI_DIR / "data"))
STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")) STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static"))
FONTS_DIR = Path(os.getenv("FONTS_DIR", OPEN_WEBUI_DIR / "static" / "fonts")) FONTS_DIR = Path(os.getenv("FONTS_DIR", OPEN_WEBUI_DIR / "static" / "fonts"))
@ -256,7 +250,6 @@ if FROM_INIT_PY:
os.getenv("FRONTEND_BUILD_DIR", OPEN_WEBUI_DIR / "frontend") os.getenv("FRONTEND_BUILD_DIR", OPEN_WEBUI_DIR / "frontend")
).resolve() ).resolve()
#################################### ####################################
# Database # Database
#################################### ####################################
@ -321,7 +314,6 @@ RESET_CONFIG_ON_START = (
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true" os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
) )
ENABLE_REALTIME_CHAT_SAVE = ( ENABLE_REALTIME_CHAT_SAVE = (
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true" os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
) )
@ -330,7 +322,9 @@ ENABLE_REALTIME_CHAT_SAVE = (
# REDIS # REDIS
#################################### ####################################
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") REDIS_URL = os.environ.get("REDIS_URL", "")
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
#################################### ####################################
# WEBUI_AUTH (Required for security) # WEBUI_AUTH (Required for security)
@ -387,6 +381,10 @@ WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL) WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
WEBSOCKET_REDIS_LOCK_TIMEOUT = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", 60) WEBSOCKET_REDIS_LOCK_TIMEOUT = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", 60)
WEBSOCKET_SENTINEL_HOSTS = os.environ.get("WEBSOCKET_SENTINEL_HOSTS", "")
WEBSOCKET_SENTINEL_PORT = os.environ.get("WEBSOCKET_SENTINEL_PORT", "26379")
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "") AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
if AIOHTTP_CLIENT_TIMEOUT == "": if AIOHTTP_CLIENT_TIMEOUT == "":
@ -399,18 +397,16 @@ else:
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get( AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get(
"AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST", "AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST",
os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", ""), os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "10"),
) )
if AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST == "": if AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST == "":
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = None AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = None
else: else:
try: try:
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = int(AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = int(AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
except Exception: except Exception:
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = 5 AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = 10
#################################### ####################################
# OFFLINE_MODE # OFFLINE_MODE
@ -424,13 +420,12 @@ if OFFLINE_MODE:
#################################### ####################################
# AUDIT LOGGING # AUDIT LOGGING
#################################### ####################################
ENABLE_AUDIT_LOGS = os.getenv("ENABLE_AUDIT_LOGS", "false").lower() == "true"
# Where to store log file # Where to store log file
AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log" AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
# Maximum size of a file before rotating into a new log file # 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") AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB")
# METADATA | REQUEST | REQUEST_RESPONSE # METADATA | REQUEST | REQUEST_RESPONSE
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "REQUEST_RESPONSE").upper() AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
try: try:
MAX_BODY_LOG_SIZE = int(os.environ.get("MAX_BODY_LOG_SIZE") or 2048) MAX_BODY_LOG_SIZE = int(os.environ.get("MAX_BODY_LOG_SIZE") or 2048)
except ValueError: except ValueError:
@ -442,3 +437,26 @@ AUDIT_EXCLUDED_PATHS = os.getenv("AUDIT_EXCLUDED_PATHS", "/chats,/chat,/folders"
) )
AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS] AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS] AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
####################################
# OPENTELEMETRY
####################################
ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true"
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
)
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
"OTEL_RESOURCE_ATTRIBUTES", ""
) # e.g. key1=val1,key2=val2
OTEL_TRACES_SAMPLER = os.environ.get(
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
).lower()
####################################
# TOOLS/FUNCTIONS PIP OPTIONS
####################################
PIP_OPTIONS = os.getenv("PIP_OPTIONS", "").split()
PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split()

View file

@ -223,6 +223,9 @@ async def generate_function_chat_completion(
extra_params = { extra_params = {
"__event_emitter__": __event_emitter__, "__event_emitter__": __event_emitter__,
"__event_call__": __event_call__, "__event_call__": __event_call__,
"__chat_id__": metadata.get("chat_id", None),
"__session_id__": metadata.get("session_id", None),
"__message_id__": metadata.get("message_id", None),
"__task__": __task__, "__task__": __task__,
"__task_body__": __task_body__, "__task_body__": __task_body__,
"__files__": files, "__files__": files,

View file

@ -84,11 +84,12 @@ from open_webui.routers.retrieval import (
get_rf, get_rf,
) )
from open_webui.internal.db import Session from open_webui.internal.db import Session, engine
from open_webui.models.functions import Functions from open_webui.models.functions import Functions
from open_webui.models.models import Models from open_webui.models.models import Models
from open_webui.models.users import UserModel, Users from open_webui.models.users import UserModel, Users
from open_webui.models.chats import Chats
from open_webui.config import ( from open_webui.config import (
LICENSE_KEY, LICENSE_KEY,
@ -155,6 +156,7 @@ from open_webui.config import (
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, PLAYWRIGHT_WS_URI,
PLAYWRIGHT_TIMEOUT,
FIRECRAWL_API_BASE_URL, FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY, FIRECRAWL_API_KEY,
RAG_WEB_LOADER_ENGINE, RAG_WEB_LOADER_ENGINE,
@ -186,9 +188,11 @@ from open_webui.config import (
CHUNK_SIZE, CHUNK_SIZE,
CONTENT_EXTRACTION_ENGINE, CONTENT_EXTRACTION_ENGINE,
TIKA_SERVER_URL, TIKA_SERVER_URL,
DOCLING_SERVER_URL,
DOCUMENT_INTELLIGENCE_ENDPOINT, DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY, DOCUMENT_INTELLIGENCE_KEY,
RAG_TOP_K, RAG_TOP_K,
RAG_TOP_K_RERANKER,
RAG_TEXT_SPLITTER, RAG_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME, TIKTOKEN_ENCODING_NAME,
PDF_EXTRACT_IMAGES, PDF_EXTRACT_IMAGES,
@ -212,6 +216,7 @@ from open_webui.config import (
SERPSTACK_API_KEY, SERPSTACK_API_KEY,
SERPSTACK_HTTPS, SERPSTACK_HTTPS,
TAVILY_API_KEY, TAVILY_API_KEY,
TAVILY_EXTRACT_DEPTH,
BING_SEARCH_V7_ENDPOINT, BING_SEARCH_V7_ENDPOINT,
BING_SEARCH_V7_SUBSCRIPTION_KEY, BING_SEARCH_V7_SUBSCRIPTION_KEY,
BRAVE_SEARCH_API_KEY, BRAVE_SEARCH_API_KEY,
@ -248,6 +253,7 @@ from open_webui.config import (
ENABLE_CHANNELS, ENABLE_CHANNELS,
ENABLE_COMMUNITY_SHARING, ENABLE_COMMUNITY_SHARING,
ENABLE_MESSAGE_RATING, ENABLE_MESSAGE_RATING,
ENABLE_USER_WEBHOOKS,
ENABLE_EVALUATION_ARENA_MODELS, ENABLE_EVALUATION_ARENA_MODELS,
USER_PERMISSIONS, USER_PERMISSIONS,
DEFAULT_USER_ROLE, DEFAULT_USER_ROLE,
@ -312,6 +318,9 @@ from open_webui.env import (
AUDIT_EXCLUDED_PATHS, AUDIT_EXCLUDED_PATHS,
AUDIT_LOG_LEVEL, AUDIT_LOG_LEVEL,
CHANGELOG, CHANGELOG,
REDIS_URL,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
GLOBAL_LOG_LEVEL, GLOBAL_LOG_LEVEL,
MAX_BODY_LOG_SIZE, MAX_BODY_LOG_SIZE,
SAFE_MODE, SAFE_MODE,
@ -327,6 +336,7 @@ from open_webui.env import (
BYPASS_MODEL_ACCESS_CONTROL, BYPASS_MODEL_ACCESS_CONTROL,
RESET_CONFIG_ON_START, RESET_CONFIG_ON_START,
OFFLINE_MODE, OFFLINE_MODE,
ENABLE_OTEL,
) )
@ -354,6 +364,8 @@ from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.tasks import stop_task, list_tasks # Import from tasks.py from open_webui.tasks import stop_task, list_tasks # Import from tasks.py
from open_webui.utils.redis import get_sentinels_from_env
if SAFE_MODE: if SAFE_MODE:
print("SAFE MODE ENABLED") print("SAFE MODE ENABLED")
@ -418,11 +430,27 @@ app = FastAPI(
oauth_manager = OAuthManager(app) oauth_manager = OAuthManager(app)
app.state.config = AppConfig() app.state.config = AppConfig(
redis_url=REDIS_URL,
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
)
app.state.WEBUI_NAME = WEBUI_NAME app.state.WEBUI_NAME = WEBUI_NAME
app.state.LICENSE_METADATA = None app.state.LICENSE_METADATA = None
########################################
#
# OPENTELEMETRY
#
########################################
if ENABLE_OTEL:
from open_webui.utils.telemetry.setup import setup as setup_opentelemetry
setup_opentelemetry(app=app, db_engine=engine)
######################################## ########################################
# #
# OLLAMA # OLLAMA
@ -492,6 +520,7 @@ app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS
app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS
app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS
@ -535,6 +564,7 @@ app.state.FUNCTIONS = {}
app.state.config.TOP_K = RAG_TOP_K app.state.config.TOP_K = RAG_TOP_K
app.state.config.TOP_K_RERANKER = RAG_TOP_K_RERANKER
app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD 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
@ -549,6 +579,7 @@ 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.DOCLING_SERVER_URL = DOCLING_SERVER_URL
app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT
app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
@ -612,8 +643,10 @@ app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = RAG_WEB_SEARCH_CONCURRENT_
app.state.config.RAG_WEB_LOADER_ENGINE = RAG_WEB_LOADER_ENGINE 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.PLAYWRIGHT_WS_URI = PLAYWRIGHT_WS_URI
app.state.config.PLAYWRIGHT_TIMEOUT = PLAYWRIGHT_TIMEOUT
app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL
app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY
app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH
app.state.EMBEDDING_FUNCTION = None app.state.EMBEDDING_FUNCTION = None
app.state.ef = None app.state.ef = None
@ -947,14 +980,24 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
return filtered_models return filtered_models
models = await get_all_models(request, user=user) all_models = await get_all_models(request, user=user)
# Filter out filter pipelines models = []
models = [ for model in all_models:
model # Filter out filter pipelines
for model in models if "pipeline" in model and model["pipeline"].get("type", None) == "filter":
if "pipeline" not in model or model["pipeline"].get("type", None) != "filter" continue
]
model_tags = [
tag.get("name")
for tag in model.get("info", {}).get("meta", {}).get("tags", [])
]
tags = [tag.get("name") for tag in model.get("tags", [])]
tags = list(set(model_tags + tags))
model["tags"] = [{"name": tag} for tag in tags]
models.append(model)
model_order_list = request.app.state.config.MODEL_ORDER_LIST model_order_list = request.app.state.config.MODEL_ORDER_LIST
if model_order_list: if model_order_list:
@ -1020,6 +1063,7 @@ async def chat_completion(
"message_id": form_data.pop("id", None), "message_id": form_data.pop("id", None),
"session_id": form_data.pop("session_id", None), "session_id": form_data.pop("session_id", None),
"tool_ids": form_data.get("tool_ids", None), "tool_ids": form_data.get("tool_ids", None),
"tool_servers": form_data.pop("tool_servers", None),
"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),
@ -1046,6 +1090,14 @@ async def chat_completion(
except Exception as e: except Exception as e:
log.debug(f"Error processing chat payload: {e}") log.debug(f"Error processing chat payload: {e}")
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": str(e)},
},
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e), detail=str(e),
@ -1181,6 +1233,7 @@ async def get_app_config(request: Request):
"enable_autocomplete_generation": app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, "enable_autocomplete_generation": app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
"enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING, "enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING,
"enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING, "enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING,
"enable_user_webhooks": app.state.config.ENABLE_USER_WEBHOOKS,
"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,

View file

@ -9,6 +9,8 @@ from open_webui.models.chats import Chats
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
from open_webui.utils.access_control import get_permissions
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"]) log.setLevel(SRC_LOG_LEVELS["MODELS"])
@ -234,15 +236,18 @@ class FolderTable:
log.error(f"update_folder: {e}") log.error(f"update_folder: {e}")
return return
def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> bool: def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, delete_chats=True
) -> bool:
try: try:
with get_db() as db: with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder: if not folder:
return False return False
# Delete all chats in the folder if delete_chats:
Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id) # Delete all chats in the folder
Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id)
# Delete all children folders # Delete all children folders
def delete_children(folder): def delete_children(folder):
@ -250,9 +255,11 @@ class FolderTable:
folder.id, user_id folder.id, user_id
) )
for folder_child in folder_children: for folder_child in folder_children:
Chats.delete_chats_by_user_id_and_folder_id( if delete_chats:
user_id, folder_child.id Chats.delete_chats_by_user_id_and_folder_id(
) user_id, folder_child.id
)
delete_children(folder_child) delete_children(folder_child)
folder = db.query(Folder).filter_by(id=folder_child.id).first() folder = db.query(Folder).filter_by(id=folder_child.id).first()

View file

@ -105,7 +105,7 @@ class TikaLoader:
if r.ok: if r.ok:
raw_metadata = r.json() raw_metadata = r.json()
text = raw_metadata.get("X-TIKA:content", "<No text content found>") text = raw_metadata.get("X-TIKA:content", "<No text content found>").strip()
if "Content-Type" in raw_metadata: if "Content-Type" in raw_metadata:
headers["Content-Type"] = raw_metadata["Content-Type"] headers["Content-Type"] = raw_metadata["Content-Type"]
@ -117,6 +117,52 @@ class TikaLoader:
raise Exception(f"Error calling Tika: {r.reason}") raise Exception(f"Error calling Tika: {r.reason}")
class DoclingLoader:
def __init__(self, url, file_path=None, mime_type=None):
self.url = url.rstrip("/")
self.file_path = file_path
self.mime_type = mime_type
def load(self) -> list[Document]:
with open(self.file_path, "rb") as f:
files = {
"files": (
self.file_path,
f,
self.mime_type or "application/octet-stream",
)
}
params = {
"image_export_mode": "placeholder",
"table_mode": "accurate",
}
endpoint = f"{self.url}/v1alpha/convert/file"
r = requests.post(endpoint, files=files, data=params)
if r.ok:
result = r.json()
document_data = result.get("document", {})
text = document_data.get("md_content", "<No text content found>")
metadata = {"Content-Type": self.mime_type} if self.mime_type else {}
log.debug("Docling extracted text: %s", text)
return [Document(page_content=text, metadata=metadata)]
else:
error_msg = f"Error calling Docling API: {r.reason}"
if r.text:
try:
error_data = r.json()
if "detail" in error_data:
error_msg += f" - {error_data['detail']}"
except Exception:
error_msg += f" - {r.text}"
raise Exception(f"Error calling Docling: {error_msg}")
class Loader: class Loader:
def __init__(self, engine: str = "", **kwargs): def __init__(self, engine: str = "", **kwargs):
self.engine = engine self.engine = engine
@ -149,6 +195,12 @@ class Loader:
file_path=file_path, file_path=file_path,
mime_type=file_content_type, mime_type=file_content_type,
) )
elif self.engine == "docling" and self.kwargs.get("DOCLING_SERVER_URL"):
loader = DoclingLoader(
url=self.kwargs.get("DOCLING_SERVER_URL"),
file_path=file_path,
mime_type=file_content_type,
)
elif ( elif (
self.engine == "document_intelligence" self.engine == "document_intelligence"
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != "" and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
@ -176,7 +228,7 @@ class Loader:
file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES") file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES")
) )
elif file_ext == "csv": elif file_ext == "csv":
loader = CSVLoader(file_path) loader = CSVLoader(file_path, autodetect_encoding=True)
elif file_ext == "rst": elif file_ext == "rst":
loader = UnstructuredRSTLoader(file_path, mode="elements") loader = UnstructuredRSTLoader(file_path, mode="elements")
elif file_ext == "xml": elif file_ext == "xml":

View file

@ -0,0 +1,93 @@
import requests
import logging
from typing import Iterator, List, Literal, Union
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class TavilyLoader(BaseLoader):
"""Extract web page content from URLs using Tavily Extract API.
This is a LangChain document loader that uses Tavily's Extract API to
retrieve content from web pages and return it as Document objects.
Args:
urls: URL or list of URLs to extract content from.
api_key: The Tavily API key.
extract_depth: Depth of extraction, either "basic" or "advanced".
continue_on_failure: Whether to continue if extraction of a URL fails.
"""
def __init__(
self,
urls: Union[str, List[str]],
api_key: str,
extract_depth: Literal["basic", "advanced"] = "basic",
continue_on_failure: bool = True,
) -> None:
"""Initialize Tavily Extract client.
Args:
urls: URL or list of URLs to extract content from.
api_key: The Tavily API key.
include_images: Whether to include images in the extraction.
extract_depth: Depth of extraction, either "basic" or "advanced".
advanced extraction retrieves more data, including tables and
embedded content, with higher success but may increase latency.
basic costs 1 credit per 5 successful URL extractions,
advanced costs 2 credits per 5 successful URL extractions.
continue_on_failure: Whether to continue if extraction of a URL fails.
"""
if not urls:
raise ValueError("At least one URL must be provided.")
self.api_key = api_key
self.urls = urls if isinstance(urls, list) else [urls]
self.extract_depth = extract_depth
self.continue_on_failure = continue_on_failure
self.api_url = "https://api.tavily.com/extract"
def lazy_load(self) -> Iterator[Document]:
"""Extract and yield documents from the URLs using Tavily Extract API."""
batch_size = 20
for i in range(0, len(self.urls), batch_size):
batch_urls = self.urls[i : i + batch_size]
try:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
# Use string for single URL, array for multiple URLs
urls_param = batch_urls[0] if len(batch_urls) == 1 else batch_urls
payload = {"urls": urls_param, "extract_depth": self.extract_depth}
# Make the API call
response = requests.post(self.api_url, headers=headers, json=payload)
response.raise_for_status()
response_data = response.json()
# Process successful results
for result in response_data.get("results", []):
url = result.get("url", "")
content = result.get("raw_content", "")
if not content:
log.warning(f"No content extracted from {url}")
continue
# Add URLs as metadata
metadata = {"source": url}
yield Document(
page_content=content,
metadata=metadata,
)
for failed in response_data.get("failed_results", []):
url = failed.get("url", "")
error = failed.get("error", "Unknown error")
log.error(f"Failed to extract content from {url}: {error}")
except Exception as e:
if self.continue_on_failure:
log.error(f"Error extracting content from batch {batch_urls}: {e}")
else:
raise e

View file

@ -1,30 +1,35 @@
import logging import logging
import os import os
import uuid
from typing import Optional, Union from typing import Optional, Union
import asyncio
import requests import requests
import hashlib import hashlib
from concurrent.futures import ThreadPoolExecutor
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
from langchain_community.retrievers import BM25Retriever from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document 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, 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.models.files import Files
from open_webui.retrieval.vector.main import GetResult
from open_webui.env import ( from open_webui.env import (
SRC_LOG_LEVELS, SRC_LOG_LEVELS,
OFFLINE_MODE, OFFLINE_MODE,
ENABLE_FORWARD_USER_INFO_HEADERS, ENABLE_FORWARD_USER_INFO_HEADERS,
) )
from open_webui.config import (
RAG_EMBEDDING_QUERY_PREFIX,
RAG_EMBEDDING_CONTENT_PREFIX,
RAG_EMBEDDING_PREFIX_FIELD_NAME,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"]) log.setLevel(SRC_LOG_LEVELS["RAG"])
@ -49,7 +54,7 @@ class VectorSearchRetriever(BaseRetriever):
) -> list[Document]: ) -> list[Document]:
result = VECTOR_DB_CLIENT.search( result = VECTOR_DB_CLIENT.search(
collection_name=self.collection_name, collection_name=self.collection_name,
vectors=[self.embedding_function(query)], vectors=[self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)],
limit=self.top_k, limit=self.top_k,
) )
@ -102,18 +107,18 @@ def get_doc(collection_name: str, user: UserModel = None):
def query_doc_with_hybrid_search( def query_doc_with_hybrid_search(
collection_name: str, collection_name: str,
collection_result: GetResult,
query: str, query: str,
embedding_function, embedding_function,
k: int, k: int,
reranking_function, reranking_function,
k_reranker: int,
r: float, r: float,
) -> dict: ) -> dict:
try: try:
result = VECTOR_DB_CLIENT.get(collection_name=collection_name)
bm25_retriever = BM25Retriever.from_texts( bm25_retriever = BM25Retriever.from_texts(
texts=result.documents[0], texts=collection_result.documents[0],
metadatas=result.metadatas[0], metadatas=collection_result.metadatas[0],
) )
bm25_retriever.k = k bm25_retriever.k = k
@ -128,7 +133,7 @@ def query_doc_with_hybrid_search(
) )
compressor = RerankCompressor( compressor = RerankCompressor(
embedding_function=embedding_function, embedding_function=embedding_function,
top_n=k, top_n=k_reranker,
reranking_function=reranking_function, reranking_function=reranking_function,
r_score=r, r_score=r,
) )
@ -138,10 +143,23 @@ def query_doc_with_hybrid_search(
) )
result = compression_retriever.invoke(query) result = compression_retriever.invoke(query)
distances = [d.metadata.get("score") for d in result]
documents = [d.page_content for d in result]
metadatas = [d.metadata for d in result]
# retrieve only min(k, k_reranker) items, sort and cut by distance if k < k_reranker
if k < k_reranker:
sorted_items = sorted(
zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True
)
sorted_items = sorted_items[:k]
distances, documents, metadatas = map(list, zip(*sorted_items))
result = { result = {
"distances": [[d.metadata.get("score") for d in result]], "distances": [distances],
"documents": [[d.page_content for d in result]], "documents": [documents],
"metadatas": [[d.metadata for d in result]], "metadatas": [metadatas],
} }
log.info( log.info(
@ -174,12 +192,9 @@ def merge_get_results(get_results: list[dict]) -> dict:
return result return result
def merge_and_sort_query_results( def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
query_results: list[dict], k: int, reverse: bool = False
) -> dict:
# Initialize lists to store combined data # Initialize lists to store combined data
combined = [] combined = dict() # To store documents with unique document hashes
seen_hashes = set() # To store unique document hashes
for data in query_results: for data in query_results:
distances = data["distances"][0] distances = data["distances"][0]
@ -192,12 +207,17 @@ def merge_and_sort_query_results(
document.encode() document.encode()
).hexdigest() # Compute a hash for uniqueness ).hexdigest() # Compute a hash for uniqueness
if doc_hash not in seen_hashes: if doc_hash not in combined.keys():
seen_hashes.add(doc_hash) combined[doc_hash] = (distance, document, metadata)
combined.append((distance, document, metadata)) continue # if doc is new, no further comparison is needed
# if doc is alredy in, but new distance is better, update
if distance > combined[doc_hash][0]:
combined[doc_hash] = (distance, document, metadata)
combined = list(combined.values())
# 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=True)
# Slice to keep only the top k elements # Slice to keep only the top k elements
sorted_distances, sorted_documents, sorted_metadatas = ( sorted_distances, sorted_documents, sorted_metadatas = (
@ -237,7 +257,7 @@ def query_collection(
) -> dict: ) -> dict:
results = [] results = []
for query in queries: for query in queries:
query_embedding = embedding_function(query) query_embedding = embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
for collection_name in collection_names: for collection_name in collection_names:
if collection_name: if collection_name:
try: try:
@ -253,12 +273,7 @@ def query_collection(
else: else:
pass pass
if VECTOR_DB == "chroma": return merge_and_sort_query_results(results, k=k)
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
return merge_and_sort_query_results(results, k=k, reverse=False)
else:
return merge_and_sort_query_results(results, k=k, reverse=True)
def query_collection_with_hybrid_search( def query_collection_with_hybrid_search(
@ -267,39 +282,66 @@ def query_collection_with_hybrid_search(
embedding_function, embedding_function,
k: int, k: int,
reranking_function, reranking_function,
k_reranker: int,
r: float, r: float,
) -> dict: ) -> dict:
results = [] results = []
error = False error = False
# Fetch collection data once per collection sequentially
# Avoid fetching the same data multiple times later
collection_results = {}
for collection_name in collection_names: for collection_name in collection_names:
try: try:
for query in queries: collection_results[collection_name] = VECTOR_DB_CLIENT.get(
result = query_doc_with_hybrid_search( collection_name=collection_name
collection_name=collection_name,
query=query,
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
r=r,
)
results.append(result)
except Exception as e:
log.exception(
"Error when querying the collection with " f"hybrid_search: {e}"
) )
error = True except Exception as e:
log.exception(f"Failed to fetch collection {collection_name}: {e}")
collection_results[collection_name] = None
if error: log.info(
f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..."
)
def process_query(collection_name, query):
try:
result = query_doc_with_hybrid_search(
collection_name=collection_name,
collection_result=collection_results[collection_name],
query=query,
embedding_function=embedding_function,
k=k,
reranking_function=reranking_function,
k_reranker=k_reranker,
r=r,
)
return result, None
except Exception as e:
log.exception(f"Error when querying the collection with hybrid_search: {e}")
return None, e
tasks = [
(collection_name, query)
for collection_name in collection_names
for query in queries
]
with ThreadPoolExecutor() as executor:
future_results = [executor.submit(process_query, cn, q) for cn, q in tasks]
task_results = [future.result() for future in future_results]
for result, err in task_results:
if err is not None:
error = True
elif result is not None:
results.append(result)
if error and not results:
raise Exception( raise Exception(
"Hybrid search failed for all collections. Using Non hybrid search as fallback." "Hybrid search failed for all collections. Using Non-hybrid search as fallback."
) )
if VECTOR_DB == "chroma": return merge_and_sort_query_results(results, k=k)
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
return merge_and_sort_query_results(results, k=k, reverse=False)
else:
return merge_and_sort_query_results(results, k=k, reverse=True)
def get_embedding_function( def get_embedding_function(
@ -311,29 +353,38 @@ def get_embedding_function(
embedding_batch_size, embedding_batch_size,
): ):
if embedding_engine == "": if embedding_engine == "":
return lambda query, user=None: embedding_function.encode(query).tolist() return lambda query, prefix=None, user=None: embedding_function.encode(
query, prompt=prefix if prefix else None
).tolist()
elif embedding_engine in ["ollama", "openai"]: elif embedding_engine in ["ollama", "openai"]:
func = lambda query, user=None: generate_embeddings( func = lambda query, prefix=None, user=None: generate_embeddings(
engine=embedding_engine, engine=embedding_engine,
model=embedding_model, model=embedding_model,
text=query, text=query,
prefix=prefix,
url=url, url=url,
key=key, key=key,
user=user, user=user,
) )
def generate_multiple(query, user, func): def generate_multiple(query, prefix, user, func):
if isinstance(query, list): if isinstance(query, list):
embeddings = [] embeddings = []
for i in range(0, len(query), embedding_batch_size): for i in range(0, len(query), embedding_batch_size):
embeddings.extend( embeddings.extend(
func(query[i : i + embedding_batch_size], user=user) func(
query[i : i + embedding_batch_size],
prefix=prefix,
user=user,
)
) )
return embeddings return embeddings
else: else:
return func(query, user) return func(query, prefix, user)
return lambda query, user=None: generate_multiple(query, user, func) return lambda query, prefix=None, user=None: generate_multiple(
query, prefix, user, func
)
else: else:
raise ValueError(f"Unknown embedding engine: {embedding_engine}") raise ValueError(f"Unknown embedding engine: {embedding_engine}")
@ -345,6 +396,7 @@ def get_sources_from_files(
embedding_function, embedding_function,
k, k,
reranking_function, reranking_function,
k_reranker,
r, r,
hybrid_search, hybrid_search,
full_context=False, full_context=False,
@ -461,6 +513,7 @@ def get_sources_from_files(
embedding_function=embedding_function, embedding_function=embedding_function,
k=k, k=k,
reranking_function=reranking_function, reranking_function=reranking_function,
k_reranker=k_reranker,
r=r, r=r,
) )
except Exception as e: except Exception as e:
@ -553,9 +606,14 @@ def generate_openai_batch_embeddings(
texts: list[str], texts: list[str],
url: str = "https://api.openai.com/v1", url: str = "https://api.openai.com/v1",
key: str = "", key: str = "",
prefix: str = None,
user: UserModel = None, user: UserModel = None,
) -> Optional[list[list[float]]]: ) -> Optional[list[list[float]]]:
try: try:
json_data = {"input": texts, "model": model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
r = requests.post( r = requests.post(
f"{url}/embeddings", f"{url}/embeddings",
headers={ headers={
@ -572,7 +630,7 @@ def generate_openai_batch_embeddings(
else {} else {}
), ),
}, },
json={"input": texts, "model": model}, json=json_data,
) )
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
@ -586,9 +644,18 @@ def generate_openai_batch_embeddings(
def generate_ollama_batch_embeddings( def generate_ollama_batch_embeddings(
model: str, texts: list[str], url: str, key: str = "", user: UserModel = None model: str,
texts: list[str],
url: str,
key: str = "",
prefix: str = None,
user: UserModel = None,
) -> Optional[list[list[float]]]: ) -> Optional[list[list[float]]]:
try: try:
json_data = {"input": texts, "model": model}
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
r = requests.post( r = requests.post(
f"{url}/api/embed", f"{url}/api/embed",
headers={ headers={
@ -605,7 +672,7 @@ def generate_ollama_batch_embeddings(
else {} else {}
), ),
}, },
json={"input": texts, "model": model}, json=json_data,
) )
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
@ -619,15 +686,34 @@ def generate_ollama_batch_embeddings(
return None return None
def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], **kwargs): def generate_embeddings(
engine: str,
model: str,
text: Union[str, list[str]],
prefix: Union[str, None] = None,
**kwargs,
):
url = kwargs.get("url", "") url = kwargs.get("url", "")
key = kwargs.get("key", "") key = kwargs.get("key", "")
user = kwargs.get("user") user = kwargs.get("user")
if prefix is not None and RAG_EMBEDDING_PREFIX_FIELD_NAME is None:
if isinstance(text, list):
text = [f"{prefix}{text_element}" for text_element in text]
else:
text = f"{prefix}{text}"
if engine == "ollama": if engine == "ollama":
if isinstance(text, list): if isinstance(text, list):
embeddings = generate_ollama_batch_embeddings( embeddings = generate_ollama_batch_embeddings(
**{"model": model, "texts": text, "url": url, "key": key, "user": user} **{
"model": model,
"texts": text,
"url": url,
"key": key,
"prefix": prefix,
"user": user,
}
) )
else: else:
embeddings = generate_ollama_batch_embeddings( embeddings = generate_ollama_batch_embeddings(
@ -636,16 +722,20 @@ def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], **
"texts": [text], "texts": [text],
"url": url, "url": url,
"key": key, "key": key,
"prefix": prefix,
"user": user, "user": user,
} }
) )
return embeddings[0] if isinstance(text, str) else embeddings return embeddings[0] if isinstance(text, str) else embeddings
elif engine == "openai": elif engine == "openai":
if isinstance(text, list): if isinstance(text, list):
embeddings = generate_openai_batch_embeddings(model, text, url, key, user) embeddings = generate_openai_batch_embeddings(
model, text, url, key, prefix, user
)
else: else:
embeddings = generate_openai_batch_embeddings(model, [text], url, key, user) embeddings = generate_openai_batch_embeddings(
model, [text], url, key, prefix, user
)
return embeddings[0] if isinstance(text, str) else embeddings return embeddings[0] if isinstance(text, str) else embeddings
@ -681,9 +771,9 @@ class RerankCompressor(BaseDocumentCompressor):
else: else:
from sentence_transformers import util from sentence_transformers import util
query_embedding = self.embedding_function(query) query_embedding = self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)
document_embedding = self.embedding_function( document_embedding = self.embedding_function(
[doc.page_content for doc in documents] [doc.page_content for doc in documents], RAG_EMBEDDING_CONTENT_PREFIX
) )
scores = util.cos_sim(query_embedding, document_embedding)[0] scores = util.cos_sim(query_embedding, document_embedding)[0]

View file

@ -75,10 +75,16 @@ class ChromaClient:
n_results=limit, n_results=limit,
) )
# chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1
# https://docs.trychroma.com/docs/collections/configure cosine equation
distances: list = result["distances"][0]
distances = [2 - dist for dist in distances]
distances = [[dist / 2 for dist in distances]]
return SearchResult( return SearchResult(
**{ **{
"ids": result["ids"], "ids": result["ids"],
"distances": result["distances"], "distances": distances,
"documents": result["documents"], "documents": result["documents"],
"metadatas": result["metadatas"], "metadatas": result["metadatas"],
} }
@ -166,12 +172,19 @@ class ChromaClient:
filter: Optional[dict] = None, filter: Optional[dict] = None,
): ):
# Delete the items from the collection based on the ids. # Delete the items from the collection based on the ids.
collection = self.client.get_collection(name=collection_name) try:
if collection: collection = self.client.get_collection(name=collection_name)
if ids: if collection:
collection.delete(ids=ids) if ids:
elif filter: collection.delete(ids=ids)
collection.delete(where=filter) elif filter:
collection.delete(where=filter)
except Exception as e:
# If collection doesn't exist, that's fine - nothing to delete
log.debug(
f"Attempted to delete from non-existent collection {collection_name}. Ignoring."
)
pass
def reset(self): def reset(self):
# Resets the database. This will delete all collections and item entries. # Resets the database. This will delete all collections and item entries.

View file

@ -64,7 +64,10 @@ class MilvusClient:
for item in match: for item in match:
_ids.append(item.get("id")) _ids.append(item.get("id"))
_distances.append(item.get("distance")) # normalize milvus score from [-1, 1] to [0, 1] range
# https://milvus.io/docs/de/metric.md
_dist = (item.get("distance") + 1.0) / 2.0
_distances.append(_dist)
_documents.append(item.get("entity", {}).get("data", {}).get("text")) _documents.append(item.get("entity", {}).get("data", {}).get("text"))
_metadatas.append(item.get("entity", {}).get("metadata")) _metadatas.append(item.get("entity", {}).get("metadata"))

View file

@ -1,4 +1,5 @@
from opensearchpy import OpenSearch from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
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
@ -21,7 +22,13 @@ class OpenSearchClient:
http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD), http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD),
) )
def _get_index_name(self, collection_name: str) -> str:
return f"{self.index_prefix}_{collection_name}"
def _result_to_get_result(self, result) -> GetResult: def _result_to_get_result(self, result) -> GetResult:
if not result["hits"]["hits"]:
return None
ids = [] ids = []
documents = [] documents = []
metadatas = [] metadatas = []
@ -31,9 +38,12 @@ class OpenSearchClient:
documents.append(hit["_source"].get("text")) documents.append(hit["_source"].get("text"))
metadatas.append(hit["_source"].get("metadata")) metadatas.append(hit["_source"].get("metadata"))
return GetResult(ids=ids, documents=documents, metadatas=metadatas) return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
def _result_to_search_result(self, result) -> SearchResult: def _result_to_search_result(self, result) -> SearchResult:
if not result["hits"]["hits"]:
return None
ids = [] ids = []
distances = [] distances = []
documents = [] documents = []
@ -46,34 +56,40 @@ class OpenSearchClient:
metadatas.append(hit["_source"].get("metadata")) metadatas.append(hit["_source"].get("metadata"))
return SearchResult( return SearchResult(
ids=ids, distances=distances, documents=documents, metadatas=metadatas ids=[ids],
distances=[distances],
documents=[documents],
metadatas=[metadatas],
) )
def _create_index(self, collection_name: str, dimension: int): def _create_index(self, collection_name: str, dimension: int):
body = { body = {
"settings": {"index": {"knn": True}},
"mappings": { "mappings": {
"properties": { "properties": {
"id": {"type": "keyword"}, "id": {"type": "keyword"},
"vector": { "vector": {
"type": "dense_vector", "type": "knn_vector",
"dims": dimension, # Adjust based on your vector dimensions "dimension": dimension, # Adjust based on your vector dimensions
"index": true, "index": True,
"similarity": "faiss", "similarity": "faiss",
"method": { "method": {
"name": "hnsw", "name": "hnsw",
"space_type": "ip", # Use inner product to approximate cosine similarity "space_type": "innerproduct", # Use inner product to approximate cosine similarity
"engine": "faiss", "engine": "faiss",
"ef_construction": 128, "parameters": {
"m": 16, "ef_construction": 128,
"m": 16,
},
}, },
}, },
"text": {"type": "text"}, "text": {"type": "text"},
"metadata": {"type": "object"}, "metadata": {"type": "object"},
} }
} },
} }
self.client.indices.create( self.client.indices.create(
index=f"{self.index_prefix}_{collection_name}", body=body index=self._get_index_name(collection_name), body=body
) )
def _create_batches(self, items: list[VectorItem], batch_size=100): def _create_batches(self, items: list[VectorItem], batch_size=100):
@ -83,39 +99,45 @@ class OpenSearchClient:
def has_collection(self, collection_name: str) -> bool: def has_collection(self, collection_name: str) -> bool:
# has_collection here means has index. # has_collection here means has index.
# We are simply adapting to the norms of the other DBs. # We are simply adapting to the norms of the other DBs.
return self.client.indices.exists( return self.client.indices.exists(index=self._get_index_name(collection_name))
index=f"{self.index_prefix}_{collection_name}"
)
def delete_colleciton(self, collection_name: str): def delete_collection(self, collection_name: str):
# delete_collection here means delete index. # delete_collection here means delete index.
# We are simply adapting to the norms of the other DBs. # We are simply adapting to the norms of the other DBs.
self.client.indices.delete(index=f"{self.index_prefix}_{collection_name}") self.client.indices.delete(index=self._get_index_name(collection_name))
def search( def search(
self, collection_name: str, vectors: list[list[float]], limit: int self, collection_name: str, vectors: list[list[float | int]], limit: int
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
query = { try:
"size": limit, if not self.has_collection(collection_name):
"_source": ["text", "metadata"], return None
"query": {
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.vector, 'vector') + 1.0",
"params": {
"vector": vectors[0]
}, # Assuming single query vector
},
}
},
}
result = self.client.search( query = {
index=f"{self.index_prefix}_{collection_name}", body=query "size": limit,
) "_source": ["text", "metadata"],
"query": {
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "(cosineSimilarity(params.query_value, doc[params.field]) + 1.0) / 2.0",
"params": {
"field": "vector",
"query_value": vectors[0],
}, # Assuming single query vector
},
}
},
}
return self._result_to_search_result(result) result = self.client.search(
index=self._get_index_name(collection_name), body=query
)
return self._result_to_search_result(result)
except Exception as e:
return None
def query( def query(
self, collection_name: str, filter: dict, limit: Optional[int] = None self, collection_name: str, filter: dict, limit: Optional[int] = None
@ -129,13 +151,15 @@ class OpenSearchClient:
} }
for field, value in filter.items(): for field, value in filter.items():
query_body["query"]["bool"]["filter"].append({"term": {field: value}}) query_body["query"]["bool"]["filter"].append(
{"match": {"metadata." + str(field): value}}
)
size = limit if limit else 10 size = limit if limit else 10
try: try:
result = self.client.search( result = self.client.search(
index=f"{self.index_prefix}_{collection_name}", index=self._get_index_name(collection_name),
body=query_body, body=query_body,
size=size, size=size,
) )
@ -146,14 +170,14 @@ class OpenSearchClient:
return None return None
def _create_index_if_not_exists(self, collection_name: str, dimension: int): def _create_index_if_not_exists(self, collection_name: str, dimension: int):
if not self.has_index(collection_name): if not self.has_collection(collection_name):
self._create_index(collection_name, dimension) self._create_index(collection_name, dimension)
def get(self, collection_name: str) -> Optional[GetResult]: def get(self, collection_name: str) -> Optional[GetResult]:
query = {"query": {"match_all": {}}, "_source": ["text", "metadata"]} query = {"query": {"match_all": {}}, "_source": ["text", "metadata"]}
result = self.client.search( result = self.client.search(
index=f"{self.index_prefix}_{collection_name}", body=query index=self._get_index_name(collection_name), body=query
) )
return self._result_to_get_result(result) return self._result_to_get_result(result)
@ -165,18 +189,18 @@ class OpenSearchClient:
for batch in self._create_batches(items): for batch in self._create_batches(items):
actions = [ actions = [
{ {
"index": { "_op_type": "index",
"_id": item["id"], "_index": self._get_index_name(collection_name),
"_source": { "_id": item["id"],
"vector": item["vector"], "_source": {
"text": item["text"], "vector": item["vector"],
"metadata": item["metadata"], "text": item["text"],
}, "metadata": item["metadata"],
} },
} }
for item in batch for item in batch
] ]
self.client.bulk(actions) bulk(self.client, actions)
def upsert(self, collection_name: str, items: list[VectorItem]): def upsert(self, collection_name: str, items: list[VectorItem]):
self._create_index_if_not_exists( self._create_index_if_not_exists(
@ -186,26 +210,47 @@ class OpenSearchClient:
for batch in self._create_batches(items): for batch in self._create_batches(items):
actions = [ actions = [
{ {
"index": { "_op_type": "update",
"_id": item["id"], "_index": self._get_index_name(collection_name),
"_index": f"{self.index_prefix}_{collection_name}", "_id": item["id"],
"_source": { "doc": {
"vector": item["vector"], "vector": item["vector"],
"text": item["text"], "text": item["text"],
"metadata": item["metadata"], "metadata": item["metadata"],
}, },
} "doc_as_upsert": True,
} }
for item in batch for item in batch
] ]
self.client.bulk(actions) bulk(self.client, actions)
def delete(self, collection_name: str, ids: list[str]): def delete(
actions = [ self,
{"delete": {"_index": f"{self.index_prefix}_{collection_name}", "_id": id}} collection_name: str,
for id in ids ids: Optional[list[str]] = None,
] filter: Optional[dict] = None,
self.client.bulk(body=actions) ):
if ids:
actions = [
{
"_op_type": "delete",
"_index": self._get_index_name(collection_name),
"_id": id,
}
for id in ids
]
bulk(self.client, actions)
elif filter:
query_body = {
"query": {"bool": {"filter": []}},
}
for field, value in filter.items():
query_body["query"]["bool"]["filter"].append(
{"match": {"metadata." + str(field): value}}
)
self.client.delete_by_query(
index=self._get_index_name(collection_name), body=query_body
)
def reset(self): def reset(self):
indices = self.client.indices.get(index=f"{self.index_prefix}_*") indices = self.client.indices.get(index=f"{self.index_prefix}_*")

View file

@ -278,7 +278,9 @@ class PgvectorClient:
for row in results: for row in results:
qid = int(row.qid) qid = int(row.qid)
ids[qid].append(row.id) ids[qid].append(row.id)
distances[qid].append(row.distance) # normalize and re-orders pgvec distance from [2, 0] to [0, 1] score range
# https://github.com/pgvector/pgvector?tab=readme-ov-file#querying
distances[qid].append((2.0 - row.distance) / 2.0)
documents[qid].append(row.text) documents[qid].append(row.text)
metadatas[qid].append(row.vmetadata) metadatas[qid].append(row.vmetadata)

View file

@ -99,7 +99,8 @@ class QdrantClient:
ids=get_result.ids, ids=get_result.ids,
documents=get_result.documents, documents=get_result.documents,
metadatas=get_result.metadatas, metadatas=get_result.metadatas,
distances=[[point.score for point in query_response.points]], # qdrant distance is [-1, 1], normalize to [0, 1]
distances=[[(point.score + 1.0) / 2.0 for point in query_response.points]],
) )
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None): def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):

View file

@ -24,13 +24,17 @@ from langchain_community.document_loaders import PlaywrightURLLoader, WebBaseLoa
from langchain_community.document_loaders.firecrawl import FireCrawlLoader from langchain_community.document_loaders.firecrawl import FireCrawlLoader
from langchain_community.document_loaders.base import BaseLoader from langchain_community.document_loaders.base import BaseLoader
from langchain_core.documents import Document from langchain_core.documents import Document
from open_webui.retrieval.loaders.tavily import TavilyLoader
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.config import ( from open_webui.config import (
ENABLE_RAG_LOCAL_WEB_FETCH, ENABLE_RAG_LOCAL_WEB_FETCH,
PLAYWRIGHT_WS_URI, PLAYWRIGHT_WS_URI,
PLAYWRIGHT_TIMEOUT,
RAG_WEB_LOADER_ENGINE, RAG_WEB_LOADER_ENGINE,
FIRECRAWL_API_BASE_URL, FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY, FIRECRAWL_API_KEY,
TAVILY_API_KEY,
TAVILY_EXTRACT_DEPTH,
) )
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
@ -113,7 +117,47 @@ def verify_ssl_cert(url: str) -> bool:
return False return False
class SafeFireCrawlLoader(BaseLoader): class RateLimitMixin:
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()
class URLProcessingMixin:
def _verify_ssl_cert(self, url: str) -> bool:
"""Verify SSL certificate for a URL."""
return verify_ssl_cert(url)
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 SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
def __init__( def __init__(
self, self,
web_paths, web_paths,
@ -184,7 +228,7 @@ class SafeFireCrawlLoader(BaseLoader):
yield from loader.lazy_load() yield from loader.lazy_load()
except Exception as e: except Exception as e:
if self.continue_on_failure: if self.continue_on_failure:
log.exception(e, "Error loading %s", url) log.exception(f"Error loading {url}: {e}")
continue continue
raise e raise e
@ -204,47 +248,124 @@ class SafeFireCrawlLoader(BaseLoader):
yield document yield document
except Exception as e: except Exception as e:
if self.continue_on_failure: if self.continue_on_failure:
log.exception(e, "Error loading %s", url) log.exception(f"Error loading {url}: {e}")
continue continue
raise e raise e
def _verify_ssl_cert(self, url: str) -> bool:
return verify_ssl_cert(url)
async def _wait_for_rate_limit(self): class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
"""Wait to respect the rate limit if specified.""" def __init__(
if self.requests_per_second and self.last_request_time: self,
min_interval = timedelta(seconds=1.0 / self.requests_per_second) web_paths: Union[str, List[str]],
time_since_last = datetime.now() - self.last_request_time api_key: str,
if time_since_last < min_interval: extract_depth: Literal["basic", "advanced"] = "basic",
await asyncio.sleep((min_interval - time_since_last).total_seconds()) continue_on_failure: bool = True,
self.last_request_time = datetime.now() requests_per_second: Optional[float] = None,
verify_ssl: bool = True,
trust_env: bool = False,
proxy: Optional[Dict[str, str]] = None,
):
"""Initialize SafeTavilyLoader with rate limiting and SSL verification support.
def _sync_wait_for_rate_limit(self): Args:
"""Synchronous version of rate limit wait.""" web_paths: List of URLs/paths to process.
if self.requests_per_second and self.last_request_time: api_key: The Tavily API key.
min_interval = timedelta(seconds=1.0 / self.requests_per_second) extract_depth: Depth of extraction ("basic" or "advanced").
time_since_last = datetime.now() - self.last_request_time continue_on_failure: Whether to continue if extraction of a URL fails.
if time_since_last < min_interval: requests_per_second: Number of requests per second to limit to.
time.sleep((min_interval - time_since_last).total_seconds()) verify_ssl: If True, verify SSL certificates.
self.last_request_time = datetime.now() trust_env: If True, use proxy settings from environment variables.
proxy: Optional proxy configuration.
"""
# Initialize proxy configuration if using environment variables
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}
async def _safe_process_url(self, url: str) -> bool: # Store parameters for creating TavilyLoader instances
"""Perform safety checks before processing a URL.""" self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths]
if self.verify_ssl and not self._verify_ssl_cert(url): self.api_key = api_key
raise ValueError(f"SSL certificate verification failed for {url}") self.extract_depth = extract_depth
await self._wait_for_rate_limit() self.continue_on_failure = continue_on_failure
return True self.verify_ssl = verify_ssl
self.trust_env = trust_env
self.proxy = proxy
def _safe_process_url_sync(self, url: str) -> bool: # Add rate limiting
"""Synchronous version of safety checks.""" self.requests_per_second = requests_per_second
if self.verify_ssl and not self._verify_ssl_cert(url): self.last_request_time = None
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit() def lazy_load(self) -> Iterator[Document]:
return True """Load documents with rate limiting support, delegating to TavilyLoader."""
valid_urls = []
for url in self.web_paths:
try:
self._safe_process_url_sync(url)
valid_urls.append(url)
except Exception as e:
log.warning(f"SSL verification failed for {url}: {str(e)}")
if not self.continue_on_failure:
raise e
if not valid_urls:
if self.continue_on_failure:
log.warning("No valid URLs to process after SSL verification")
return
raise ValueError("No valid URLs to process after SSL verification")
try:
loader = TavilyLoader(
urls=valid_urls,
api_key=self.api_key,
extract_depth=self.extract_depth,
continue_on_failure=self.continue_on_failure,
)
yield from loader.lazy_load()
except Exception as e:
if self.continue_on_failure:
log.exception(f"Error extracting content from URLs: {e}")
else:
raise e
async def alazy_load(self) -> AsyncIterator[Document]:
"""Async version with rate limiting and SSL verification."""
valid_urls = []
for url in self.web_paths:
try:
await self._safe_process_url(url)
valid_urls.append(url)
except Exception as e:
log.warning(f"SSL verification failed for {url}: {str(e)}")
if not self.continue_on_failure:
raise e
if not valid_urls:
if self.continue_on_failure:
log.warning("No valid URLs to process after SSL verification")
return
raise ValueError("No valid URLs to process after SSL verification")
try:
loader = TavilyLoader(
urls=valid_urls,
api_key=self.api_key,
extract_depth=self.extract_depth,
continue_on_failure=self.continue_on_failure,
)
async for document in loader.alazy_load():
yield document
except Exception as e:
if self.continue_on_failure:
log.exception(f"Error loading URLs: {e}")
else:
raise e
class SafePlaywrightURLLoader(PlaywrightURLLoader): class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessingMixin):
"""Load HTML pages safely with Playwright, supporting SSL verification, rate limiting, and remote browser connection. """Load HTML pages safely with Playwright, supporting SSL verification, rate limiting, and remote browser connection.
Attributes: Attributes:
@ -256,6 +377,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
headless (bool): If True, the browser will run in headless mode. headless (bool): If True, the browser will run in headless mode.
proxy (dict): Proxy override settings for the Playwright session. proxy (dict): Proxy override settings for the Playwright session.
playwright_ws_url (Optional[str]): WebSocket endpoint URI for remote browser connection. playwright_ws_url (Optional[str]): WebSocket endpoint URI for remote browser connection.
playwright_timeout (Optional[int]): Maximum operation time in milliseconds.
""" """
def __init__( def __init__(
@ -269,6 +391,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
remove_selectors: Optional[List[str]] = None, remove_selectors: Optional[List[str]] = None,
proxy: Optional[Dict[str, str]] = None, proxy: Optional[Dict[str, str]] = None,
playwright_ws_url: Optional[str] = None, playwright_ws_url: Optional[str] = None,
playwright_timeout: Optional[int] = 10000,
): ):
"""Initialize with additional safety parameters and remote browser support.""" """Initialize with additional safety parameters and remote browser support."""
@ -295,6 +418,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
self.last_request_time = None self.last_request_time = None
self.playwright_ws_url = playwright_ws_url self.playwright_ws_url = playwright_ws_url
self.trust_env = trust_env self.trust_env = trust_env
self.playwright_timeout = playwright_timeout
def lazy_load(self) -> Iterator[Document]: def lazy_load(self) -> Iterator[Document]:
"""Safely load URLs synchronously with support for remote browser.""" """Safely load URLs synchronously with support for remote browser."""
@ -311,7 +435,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
try: try:
self._safe_process_url_sync(url) self._safe_process_url_sync(url)
page = browser.new_page() page = browser.new_page()
response = page.goto(url) response = page.goto(url, timeout=self.playwright_timeout)
if response is None: if response is None:
raise ValueError(f"page.goto() returned None for url {url}") raise ValueError(f"page.goto() returned None for url {url}")
@ -320,7 +444,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
yield Document(page_content=text, metadata=metadata) yield Document(page_content=text, metadata=metadata)
except Exception as e: except Exception as e:
if self.continue_on_failure: if self.continue_on_failure:
log.exception(e, "Error loading %s", url) log.exception(f"Error loading {url}: {e}")
continue continue
raise e raise e
browser.close() browser.close()
@ -342,7 +466,7 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
try: try:
await self._safe_process_url(url) await self._safe_process_url(url)
page = await browser.new_page() page = await browser.new_page()
response = await page.goto(url) response = await page.goto(url, timeout=self.playwright_timeout)
if response is None: if response is None:
raise ValueError(f"page.goto() returned None for url {url}") raise ValueError(f"page.goto() returned None for url {url}")
@ -351,46 +475,11 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader):
yield Document(page_content=text, metadata=metadata) yield Document(page_content=text, metadata=metadata)
except Exception as e: except Exception as e:
if self.continue_on_failure: if self.continue_on_failure:
log.exception(e, "Error loading %s", url) log.exception(f"Error loading {url}: {e}")
continue continue
raise e raise e
await browser.close() 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."""
@ -472,7 +561,7 @@ class SafeWebBaseLoader(WebBaseLoader):
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.exception(e, "Error loading %s", path) log.exception(f"Error loading {path}: {e}")
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."""
@ -499,6 +588,7 @@ RAG_WEB_LOADER_ENGINES = defaultdict(lambda: SafeWebBaseLoader)
RAG_WEB_LOADER_ENGINES["playwright"] = SafePlaywrightURLLoader RAG_WEB_LOADER_ENGINES["playwright"] = SafePlaywrightURLLoader
RAG_WEB_LOADER_ENGINES["safe_web"] = SafeWebBaseLoader RAG_WEB_LOADER_ENGINES["safe_web"] = SafeWebBaseLoader
RAG_WEB_LOADER_ENGINES["firecrawl"] = SafeFireCrawlLoader RAG_WEB_LOADER_ENGINES["firecrawl"] = SafeFireCrawlLoader
RAG_WEB_LOADER_ENGINES["tavily"] = SafeTavilyLoader
def get_web_loader( def get_web_loader(
@ -518,13 +608,19 @@ def get_web_loader(
"trust_env": trust_env, "trust_env": trust_env,
} }
if PLAYWRIGHT_WS_URI.value: if RAG_WEB_LOADER_ENGINE.value == "playwright":
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URI.value web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value * 1000
if PLAYWRIGHT_WS_URI.value:
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URI.value
if RAG_WEB_LOADER_ENGINE.value == "firecrawl": if RAG_WEB_LOADER_ENGINE.value == "firecrawl":
web_loader_args["api_key"] = FIRECRAWL_API_KEY.value web_loader_args["api_key"] = FIRECRAWL_API_KEY.value
web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value
if RAG_WEB_LOADER_ENGINE.value == "tavily":
web_loader_args["api_key"] = TAVILY_API_KEY.value
web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value
# Create the appropriate WebLoader based on the configuration # Create the appropriate WebLoader based on the configuration
WebLoaderClass = RAG_WEB_LOADER_ENGINES[RAG_WEB_LOADER_ENGINE.value] WebLoaderClass = RAG_WEB_LOADER_ENGINES[RAG_WEB_LOADER_ENGINE.value]
web_loader = WebLoaderClass(**web_loader_args) web_loader = WebLoaderClass(**web_loader_args)

View file

@ -625,7 +625,9 @@ def transcription(
): ):
log.info(f"file.content_type: {file.content_type}") log.info(f"file.content_type: {file.content_type}")
if file.content_type not in ["audio/mpeg", "audio/wav", "audio/ogg", "audio/x-m4a"]: supported_filetypes = ("audio/mpeg", "audio/wav", "audio/ogg", "audio/x-m4a")
if not file.content_type.startswith(supported_filetypes):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED, detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,

View file

@ -210,7 +210,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
LDAP_APP_DN, LDAP_APP_DN,
LDAP_APP_PASSWORD, LDAP_APP_PASSWORD,
auto_bind="NONE", auto_bind="NONE",
authentication="SIMPLE", authentication="SIMPLE" if LDAP_APP_DN else "ANONYMOUS",
) )
if not connection_app.bind(): if not connection_app.bind():
raise HTTPException(400, detail="Application account bind failed") raise HTTPException(400, detail="Application account bind failed")
@ -639,11 +639,12 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY, "ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS, "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS, "API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE, "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
} }
@ -654,11 +655,12 @@ class AdminConfig(BaseModel):
ENABLE_API_KEY: bool ENABLE_API_KEY: bool
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool
API_KEY_ALLOWED_ENDPOINTS: str API_KEY_ALLOWED_ENDPOINTS: str
ENABLE_CHANNELS: bool
DEFAULT_USER_ROLE: str DEFAULT_USER_ROLE: str
JWT_EXPIRES_IN: str JWT_EXPIRES_IN: str
ENABLE_COMMUNITY_SHARING: bool ENABLE_COMMUNITY_SHARING: bool
ENABLE_MESSAGE_RATING: bool ENABLE_MESSAGE_RATING: bool
ENABLE_CHANNELS: bool
ENABLE_USER_WEBHOOKS: bool
@router.post("/admin/config") @router.post("/admin/config")
@ -693,6 +695,8 @@ async def update_admin_config(
) )
request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
return { return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"WEBUI_URL": request.app.state.config.WEBUI_URL, "WEBUI_URL": request.app.state.config.WEBUI_URL,
@ -705,6 +709,7 @@ async def update_admin_config(
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
} }

View file

@ -2,6 +2,8 @@ import json
import logging import logging
from typing import Optional from typing import Optional
from open_webui.socket.main import get_event_emitter
from open_webui.models.chats import ( from open_webui.models.chats import (
ChatForm, ChatForm,
ChatImportForm, ChatImportForm,
@ -372,6 +374,107 @@ async def update_chat_by_id(
) )
############################
# UpdateChatMessageById
############################
class MessageForm(BaseModel):
content: str
@router.post("/{id}/messages/{message_id}", response_model=Optional[ChatResponse])
async def update_chat_message_by_id(
id: str, message_id: str, form_data: MessageForm, user=Depends(get_verified_user)
):
chat = Chats.get_chat_by_id(id)
if not chat:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if chat.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chat = Chats.upsert_message_to_chat_by_id_and_message_id(
id,
message_id,
{
"content": form_data.content,
},
)
event_emitter = get_event_emitter(
{
"user_id": user.id,
"chat_id": id,
"message_id": message_id,
},
False,
)
if event_emitter:
await event_emitter(
{
"type": "chat:message",
"data": {
"chat_id": id,
"message_id": message_id,
"content": form_data.content,
},
}
)
return ChatResponse(**chat.model_dump())
############################
# SendChatMessageEventById
############################
class EventForm(BaseModel):
type: str
data: dict
@router.post("/{id}/messages/{message_id}/event", response_model=Optional[bool])
async def send_chat_message_event_by_id(
id: str, message_id: str, form_data: EventForm, user=Depends(get_verified_user)
):
chat = Chats.get_chat_by_id(id)
if not chat:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if chat.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
event_emitter = get_event_emitter(
{
"user_id": user.id,
"chat_id": id,
"message_id": message_id,
}
)
try:
if event_emitter:
await event_emitter(form_data.model_dump())
else:
return False
return True
except:
return False
############################ ############################
# DeleteChatById # DeleteChatById
############################ ############################

View file

@ -56,8 +56,19 @@ async def update_config(
} }
class FeedbackUserReponse(BaseModel):
id: str
name: str
email: str
role: str = "pending"
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
class FeedbackUserResponse(FeedbackResponse): class FeedbackUserResponse(FeedbackResponse):
user: Optional[UserModel] = None user: Optional[FeedbackUserReponse] = None
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse]) @router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
@ -65,7 +76,10 @@ async def get_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks() feedbacks = Feedbacks.get_all_feedbacks()
return [ return [
FeedbackUserResponse( FeedbackUserResponse(
**feedback.model_dump(), user=Users.get_user_by_id(feedback.user_id) **feedback.model_dump(),
user=FeedbackUserReponse(
**Users.get_user_by_id(feedback.user_id).model_dump()
),
) )
for feedback in feedbacks for feedback in feedbacks
] ]

View file

@ -5,7 +5,16 @@ from pathlib import Path
from typing import Optional from typing import Optional
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status from fastapi import (
APIRouter,
Depends,
File,
HTTPException,
Request,
UploadFile,
status,
Query,
)
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
@ -15,6 +24,9 @@ from open_webui.models.files import (
FileModelResponse, FileModelResponse,
Files, Files,
) )
from open_webui.models.knowledge import Knowledges
from open_webui.routers.knowledge import get_knowledge, get_knowledge_list
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.routers.audio import transcribe
from open_webui.storage.provider import Storage from open_webui.storage.provider import Storage
@ -27,6 +39,39 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter() router = APIRouter()
############################
# Check if the current user has access to a file through any knowledge bases the user may be in.
############################
def has_access_to_file(
file_id: Optional[str], access_type: str, user=Depends(get_verified_user)
) -> bool:
file = Files.get_file_by_id(file_id)
log.debug(f"Checking if user has {access_type} access to file")
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
has_access = False
knowledge_base_id = file.meta.get("collection_name") if file.meta else None
if knowledge_base_id:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(
user.id, access_type
)
for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id:
has_access = True
break
return has_access
############################ ############################
# Upload File # Upload File
############################ ############################
@ -38,6 +83,7 @@ def upload_file(
file: UploadFile = File(...), file: UploadFile = File(...),
user=Depends(get_verified_user), user=Depends(get_verified_user),
file_metadata: dict = {}, file_metadata: dict = {},
process: bool = Query(True),
): ):
log.info(f"file.content_type: {file.content_type}") log.info(f"file.content_type: {file.content_type}")
try: try:
@ -66,34 +112,33 @@ def upload_file(
} }
), ),
) )
if process:
try: try:
if file.content_type in [ if file.content_type in [
"audio/mpeg", "audio/mpeg",
"audio/wav", "audio/wav",
"audio/ogg", "audio/ogg",
"audio/x-m4a", "audio/x-m4a",
]: ]:
file_path = Storage.get_file(file_path) file_path = Storage.get_file(file_path)
result = transcribe(request, file_path) result = transcribe(request, file_path)
process_file( process_file(
request, request,
ProcessFileForm(file_id=id, content=result.get("text", "")), ProcessFileForm(file_id=id, content=result.get("text", "")),
user=user, user=user,
)
elif file.content_type not in ["image/png", "image/jpeg", "image/gif"]:
process_file(request, ProcessFileForm(file_id=id), user=user)
file_item = Files.get_file_by_id(id=id)
except Exception as e:
log.exception(e)
log.error(f"Error processing file: {file_item.id}")
file_item = FileModelResponse(
**{
**file_item.model_dump(),
"error": str(e.detail) if hasattr(e, "detail") else str(e),
}
) )
else:
process_file(request, ProcessFileForm(file_id=id), user=user)
file_item = Files.get_file_by_id(id=id)
except Exception as e:
log.exception(e)
log.error(f"Error processing file: {file_item.id}")
file_item = FileModelResponse(
**{
**file_item.model_dump(),
"error": str(e.detail) if hasattr(e, "detail") else str(e),
}
)
if file_item: if file_item:
return file_item return file_item
@ -160,7 +205,17 @@ async def delete_all_files(user=Depends(get_admin_user)):
async def get_file_by_id(id: str, user=Depends(get_verified_user)): async def get_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id) file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"): if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
):
return file return file
else: else:
raise HTTPException( raise HTTPException(
@ -178,7 +233,17 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)): async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id) file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"): if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
):
return {"content": file.data.get("content", "")} return {"content": file.data.get("content", "")}
else: else:
raise HTTPException( raise HTTPException(
@ -202,7 +267,17 @@ async def update_file_data_content_by_id(
): ):
file = Files.get_file_by_id(id) file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"): if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "write", user)
):
try: try:
process_file( process_file(
request, request,
@ -228,9 +303,22 @@ async def update_file_data_content_by_id(
@router.get("/{id}/content") @router.get("/{id}/content")
async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): async def get_file_content_by_id(
id: str, user=Depends(get_verified_user), attachment: bool = Query(False)
):
file = Files.get_file_by_id(id) file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
):
try: try:
file_path = Storage.get_file(file.path) file_path = Storage.get_file(file.path)
file_path = Path(file_path) file_path = Path(file_path)
@ -246,17 +334,22 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
encoded_filename = quote(filename) encoded_filename = quote(filename)
headers = {} headers = {}
if content_type == "application/pdf" or filename.lower().endswith( if attachment:
".pdf"
):
headers["Content-Disposition"] = (
f"inline; filename*=UTF-8''{encoded_filename}"
)
content_type = "application/pdf"
elif content_type != "text/plain":
headers["Content-Disposition"] = ( headers["Content-Disposition"] = (
f"attachment; filename*=UTF-8''{encoded_filename}" f"attachment; filename*=UTF-8''{encoded_filename}"
) )
else:
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) return FileResponse(file_path, headers=headers, media_type=content_type)
@ -282,7 +375,18 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/{id}/content/html") @router.get("/{id}/content/html")
async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)): async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id) file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
):
try: try:
file_path = Storage.get_file(file.path) file_path = Storage.get_file(file.path)
file_path = Path(file_path) file_path = Path(file_path)
@ -314,7 +418,17 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id) file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"): if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
):
file_path = file.path file_path = file.path
# Handle Unicode filenames # Handle Unicode filenames
@ -365,7 +479,18 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.delete("/{id}") @router.delete("/{id}")
async def delete_file_by_id(id: str, user=Depends(get_verified_user)): async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id) file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "write", user)
):
# We should add Chroma cleanup here # We should add Chroma cleanup here
result = Files.delete_file_by_id(id) result = Files.delete_file_by_id(id)

View file

@ -20,11 +20,13 @@ from open_webui.env import SRC_LOG_LEVELS
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
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_permission
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"]) log.setLevel(SRC_LOG_LEVELS["MODELS"])
@ -228,7 +230,18 @@ async def update_folder_is_expanded_by_id(
@router.delete("/{id}") @router.delete("/{id}")
async def delete_folder_by_id(id: str, user=Depends(get_verified_user)): async def delete_folder_by_id(
request: Request, id: str, user=Depends(get_verified_user)
):
chat_delete_permission = has_permission(
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
)
if not chat_delete_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folder = Folders.get_folder_by_id_and_user_id(id, user.id) folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder: if folder:
try: try:

View file

@ -517,10 +517,8 @@ async def image_generations(
images = [] images = []
for image in res["data"]: for image in res["data"]:
if "url" in image: if image_url := image.get("url", None):
image_data, content_type = load_url_image_data( image_data, content_type = load_url_image_data(image_url, headers)
image["url"], headers
)
else: else:
image_data, content_type = load_b64_image_data(image["b64_json"]) image_data, content_type = load_b64_image_data(image["b64_json"])

View file

@ -437,14 +437,24 @@ def remove_file_from_knowledge_by_id(
) )
# Remove content from the vector database # Remove content from the vector database
VECTOR_DB_CLIENT.delete( try:
collection_name=knowledge.id, filter={"file_id": form_data.file_id} VECTOR_DB_CLIENT.delete(
) collection_name=knowledge.id, filter={"file_id": form_data.file_id}
)
except Exception as e:
log.debug("This was most likely caused by bypassing embedding processing")
log.debug(e)
pass
# Remove the file's collection from vector database try:
file_collection = f"file-{form_data.file_id}" # Remove the file's collection from vector database
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): file_collection = f"file-{form_data.file_id}"
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
except Exception as e:
log.debug("This was most likely caused by bypassing embedding processing")
log.debug(e)
pass
# Delete file from database # Delete file from database
Files.delete_file_by_id(form_data.file_id) Files.delete_file_by_id(form_data.file_id)

View file

@ -57,7 +57,9 @@ async def add_memory(
{ {
"id": memory.id, "id": memory.id,
"text": memory.content, "text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user), "vector": request.app.state.EMBEDDING_FUNCTION(
memory.content, user=user
),
"metadata": {"created_at": memory.created_at}, "metadata": {"created_at": memory.created_at},
} }
], ],
@ -82,7 +84,7 @@ async def query_memory(
): ):
results = VECTOR_DB_CLIENT.search( results = VECTOR_DB_CLIENT.search(
collection_name=f"user-memory-{user.id}", collection_name=f"user-memory-{user.id}",
vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user)], vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)],
limit=form_data.k, limit=form_data.k,
) )
@ -105,7 +107,9 @@ async def reset_memory_from_vector_db(
{ {
"id": memory.id, "id": memory.id,
"text": memory.content, "text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user), "vector": request.app.state.EMBEDDING_FUNCTION(
memory.content, user=user
),
"metadata": { "metadata": {
"created_at": memory.created_at, "created_at": memory.created_at,
"updated_at": memory.updated_at, "updated_at": memory.updated_at,
@ -161,7 +165,7 @@ async def update_memory_by_id(
"id": memory.id, "id": memory.id,
"text": memory.content, "text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION( "vector": request.app.state.EMBEDDING_FUNCTION(
memory.content, user memory.content, user=user
), ),
"metadata": { "metadata": {
"created_at": memory.created_at, "created_at": memory.created_at,

View file

@ -295,7 +295,7 @@ async def update_config(
} }
@cached(ttl=3) @cached(ttl=1)
async def get_all_models(request: Request, user: UserModel = None): 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:
@ -336,6 +336,7 @@ async def get_all_models(request: Request, user: UserModel = None):
) )
prefix_id = api_config.get("prefix_id", None) prefix_id = api_config.get("prefix_id", None)
tags = api_config.get("tags", [])
model_ids = api_config.get("model_ids", []) model_ids = api_config.get("model_ids", [])
if len(model_ids) != 0 and "models" in response: if len(model_ids) != 0 and "models" in response:
@ -350,6 +351,10 @@ async def get_all_models(request: Request, user: UserModel = None):
for model in response.get("models", []): for model in response.get("models", []):
model["model"] = f"{prefix_id}.{model['model']}" model["model"] = f"{prefix_id}.{model['model']}"
if tags:
for model in response.get("models", []):
model["tags"] = tags
def merge_models_lists(model_lists): def merge_models_lists(model_lists):
merged_models = {} merged_models = {}
@ -460,18 +465,27 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
if request.app.state.config.ENABLE_OLLAMA_API: if request.app.state.config.ENABLE_OLLAMA_API:
if url_idx is None: if url_idx is None:
# returns lowest version # returns lowest version
request_tasks = [ request_tasks = []
send_get_request(
f"{url}/api/version", for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get( request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx), url, {}
request.app.state.config.OLLAMA_API_CONFIGS.get( ), # Legacy support
url, {}
), # Legacy support
).get("key", None),
) )
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
] enable = api_config.get("enable", True)
key = api_config.get("key", None)
if enable:
request_tasks.append(
send_get_request(
f"{url}/api/version",
key,
)
)
responses = await asyncio.gather(*request_tasks) responses = await asyncio.gather(*request_tasks)
responses = list(filter(lambda x: x is not None, responses)) responses = list(filter(lambda x: x is not None, responses))
@ -1164,7 +1178,7 @@ async def generate_chat_completion(
prefix_id = api_config.get("prefix_id", None) prefix_id = api_config.get("prefix_id", None)
if prefix_id: if prefix_id:
payload["model"] = payload["model"].replace(f"{prefix_id}.", "") payload["model"] = payload["model"].replace(f"{prefix_id}.", "")
# payload["keep_alive"] = -1 # keep alive forever
return await send_post_request( return await send_post_request(
url=f"{url}/api/chat", url=f"{url}/api/chat",
payload=json.dumps(payload), payload=json.dumps(payload),

View file

@ -36,6 +36,9 @@ from open_webui.utils.payload import (
apply_model_params_to_body_openai, apply_model_params_to_body_openai,
apply_model_system_prompt_to_body, apply_model_system_prompt_to_body,
) )
from open_webui.utils.misc import (
convert_logit_bias_input_to_json,
)
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 from open_webui.utils.access_control import has_access
@ -350,6 +353,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
) )
prefix_id = api_config.get("prefix_id", None) prefix_id = api_config.get("prefix_id", None)
tags = api_config.get("tags", [])
if prefix_id: if prefix_id:
for model in ( for model in (
@ -357,6 +361,12 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
): ):
model["id"] = f"{prefix_id}.{model['id']}" model["id"] = f"{prefix_id}.{model['id']}"
if tags:
for model in (
response if isinstance(response, list) else response.get("data", [])
):
model["tags"] = tags
log.debug(f"get_all_models:responses() {responses}") log.debug(f"get_all_models:responses() {responses}")
return responses return responses
@ -374,7 +384,7 @@ async def get_filtered_models(models, user):
return filtered_models return filtered_models
@cached(ttl=3) @cached(ttl=1)
async def get_all_models(request: Request, user: UserModel) -> 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()")
@ -396,6 +406,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
for idx, models in enumerate(model_lists): for idx, models in enumerate(model_lists):
if models is not None and "error" not in models: if models is not None and "error" not in models:
merged_list.extend( merged_list.extend(
[ [
{ {
@ -406,18 +417,21 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
"urlIdx": idx, "urlIdx": idx,
} }
for model in models for model in models
if "api.openai.com" if (model.get("id") or model.get("name"))
not in request.app.state.config.OPENAI_API_BASE_URLS[idx] and (
or not any( "api.openai.com"
name in model["id"] not in request.app.state.config.OPENAI_API_BASE_URLS[idx]
for name in [ or not any(
"babbage", name in model["id"]
"dall-e", for name in [
"davinci", "babbage",
"embedding", "dall-e",
"tts", "davinci",
"whisper", "embedding",
] "tts",
"whisper",
]
)
) )
] ]
) )
@ -666,6 +680,11 @@ async def generate_chat_completion(
del payload["max_tokens"] del payload["max_tokens"]
# Convert the modified body back to JSON # Convert the modified body back to JSON
if "logit_bias" in payload:
payload["logit_bias"] = json.loads(
convert_logit_bias_input_to_json(payload["logit_bias"])
)
payload = json.dumps(payload) payload = json.dumps(payload)
r = None r = None

View file

@ -90,8 +90,8 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
headers=headers, headers=headers,
json=request_data, json=request_data,
) as response: ) as response:
response.raise_for_status()
payload = await response.json() payload = await response.json()
response.raise_for_status()
except aiohttp.ClientResponseError as e: except aiohttp.ClientResponseError as e:
res = ( res = (
await response.json() await response.json()
@ -139,8 +139,8 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
headers=headers, headers=headers,
json=request_data, json=request_data,
) as response: ) as response:
response.raise_for_status()
payload = await response.json() payload = await response.json()
response.raise_for_status()
except aiohttp.ClientResponseError as e: except aiohttp.ClientResponseError as e:
try: try:
res = ( res = (

View file

@ -74,7 +74,6 @@ from open_webui.utils.misc import (
) )
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.config import ( from open_webui.config import (
ENV, ENV,
RAG_EMBEDDING_MODEL_AUTO_UPDATE, RAG_EMBEDDING_MODEL_AUTO_UPDATE,
@ -83,6 +82,8 @@ from open_webui.config import (
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
UPLOAD_DIR, UPLOAD_DIR,
DEFAULT_LOCALE, DEFAULT_LOCALE,
RAG_EMBEDDING_CONTENT_PREFIX,
RAG_EMBEDDING_QUERY_PREFIX,
) )
from open_webui.env import ( from open_webui.env import (
SRC_LOG_LEVELS, SRC_LOG_LEVELS,
@ -358,6 +359,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"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,
"docling_server_url": request.app.state.config.DOCLING_SERVER_URL,
"document_intelligence_config": { "document_intelligence_config": {
"endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, "endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, "key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
@ -428,6 +430,7 @@ class DocumentIntelligenceConfigForm(BaseModel):
class ContentExtractionConfig(BaseModel): class ContentExtractionConfig(BaseModel):
engine: str = "" engine: str = ""
tika_server_url: Optional[str] = None tika_server_url: Optional[str] = None
docling_server_url: Optional[str] = None
document_intelligence_config: Optional[DocumentIntelligenceConfigForm] = None document_intelligence_config: Optional[DocumentIntelligenceConfigForm] = None
@ -540,6 +543,9 @@ async def update_rag_config(
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
) )
request.app.state.config.DOCLING_SERVER_URL = (
form_data.content_extraction.docling_server_url
)
if form_data.content_extraction.document_intelligence_config is not None: if form_data.content_extraction.document_intelligence_config is not None:
request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = ( request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = (
form_data.content_extraction.document_intelligence_config.endpoint form_data.content_extraction.document_intelligence_config.endpoint
@ -648,6 +654,7 @@ 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,
"docling_server_url": request.app.state.config.DOCLING_SERVER_URL,
"document_intelligence_config": { "document_intelligence_config": {
"endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, "endpoint": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, "key": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
@ -713,6 +720,7 @@ async def get_query_settings(request: Request, user=Depends(get_admin_user)):
"status": True, "status": True,
"template": request.app.state.config.RAG_TEMPLATE, "template": request.app.state.config.RAG_TEMPLATE,
"k": request.app.state.config.TOP_K, "k": request.app.state.config.TOP_K,
"k_reranker": request.app.state.config.TOP_K_RERANKER,
"r": request.app.state.config.RELEVANCE_THRESHOLD, "r": request.app.state.config.RELEVANCE_THRESHOLD,
"hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, "hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
} }
@ -720,6 +728,7 @@ async def get_query_settings(request: Request, user=Depends(get_admin_user)):
class QuerySettingsForm(BaseModel): class QuerySettingsForm(BaseModel):
k: Optional[int] = None k: Optional[int] = None
k_reranker: Optional[int] = None
r: Optional[float] = None r: Optional[float] = None
template: Optional[str] = None template: Optional[str] = None
hybrid: Optional[bool] = None hybrid: Optional[bool] = None
@ -731,6 +740,7 @@ async def update_query_settings(
): ):
request.app.state.config.RAG_TEMPLATE = form_data.template request.app.state.config.RAG_TEMPLATE = form_data.template
request.app.state.config.TOP_K = form_data.k if form_data.k else 4 request.app.state.config.TOP_K = form_data.k if form_data.k else 4
request.app.state.config.TOP_K_RERANKER = form_data.k_reranker or 4
request.app.state.config.RELEVANCE_THRESHOLD = form_data.r if form_data.r else 0.0 request.app.state.config.RELEVANCE_THRESHOLD = form_data.r if form_data.r else 0.0
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = ( request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = (
@ -741,6 +751,7 @@ async def update_query_settings(
"status": True, "status": True,
"template": request.app.state.config.RAG_TEMPLATE, "template": request.app.state.config.RAG_TEMPLATE,
"k": request.app.state.config.TOP_K, "k": request.app.state.config.TOP_K,
"k_reranker": request.app.state.config.TOP_K_RERANKER,
"r": request.app.state.config.RELEVANCE_THRESHOLD, "r": request.app.state.config.RELEVANCE_THRESHOLD,
"hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, "hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
} }
@ -881,7 +892,9 @@ def save_docs_to_vector_db(
) )
embeddings = embedding_function( embeddings = embedding_function(
list(map(lambda x: x.replace("\n", " "), texts)), user=user list(map(lambda x: x.replace("\n", " "), texts)),
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
user=user,
) )
items = [ items = [
@ -990,6 +1003,7 @@ def process_file(
loader = Loader( loader = Loader(
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,
DOCLING_SERVER_URL=request.app.state.config.DOCLING_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_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
@ -1488,6 +1502,7 @@ class QueryDocForm(BaseModel):
collection_name: str collection_name: str
query: str query: str
k: Optional[int] = None k: Optional[int] = None
k_reranker: Optional[int] = None
r: Optional[float] = None r: Optional[float] = None
hybrid: Optional[bool] = None hybrid: Optional[bool] = None
@ -1503,11 +1518,13 @@ def query_doc_handler(
return query_doc_with_hybrid_search( return query_doc_with_hybrid_search(
collection_name=form_data.collection_name, collection_name=form_data.collection_name,
query=form_data.query, query=form_data.query,
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, user=user query, prefix=prefix, user=user
), ),
k=form_data.k if form_data.k else request.app.state.config.TOP_K, k=form_data.k if form_data.k else request.app.state.config.TOP_K,
reranking_function=request.app.state.rf, reranking_function=request.app.state.rf,
k_reranker=form_data.k_reranker
or request.app.state.config.TOP_K_RERANKER,
r=( r=(
form_data.r form_data.r
if form_data.r if form_data.r
@ -1519,7 +1536,7 @@ def query_doc_handler(
return query_doc( return query_doc(
collection_name=form_data.collection_name, collection_name=form_data.collection_name,
query_embedding=request.app.state.EMBEDDING_FUNCTION( query_embedding=request.app.state.EMBEDDING_FUNCTION(
form_data.query, user=user form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user
), ),
k=form_data.k if form_data.k else request.app.state.config.TOP_K, k=form_data.k if form_data.k else request.app.state.config.TOP_K,
user=user, user=user,
@ -1536,6 +1553,7 @@ class QueryCollectionsForm(BaseModel):
collection_names: list[str] collection_names: list[str]
query: str query: str
k: Optional[int] = None k: Optional[int] = None
k_reranker: Optional[int] = None
r: Optional[float] = None r: Optional[float] = None
hybrid: Optional[bool] = None hybrid: Optional[bool] = None
@ -1551,11 +1569,13 @@ def query_collection_handler(
return query_collection_with_hybrid_search( return query_collection_with_hybrid_search(
collection_names=form_data.collection_names, collection_names=form_data.collection_names,
queries=[form_data.query], queries=[form_data.query],
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, user=user query, prefix=prefix, user=user
), ),
k=form_data.k if form_data.k else request.app.state.config.TOP_K, k=form_data.k if form_data.k else request.app.state.config.TOP_K,
reranking_function=request.app.state.rf, reranking_function=request.app.state.rf,
k_reranker=form_data.k_reranker
or request.app.state.config.TOP_K_RERANKER,
r=( r=(
form_data.r form_data.r
if form_data.r if form_data.r
@ -1566,8 +1586,8 @@ def query_collection_handler(
return query_collection( return query_collection(
collection_names=form_data.collection_names, collection_names=form_data.collection_names,
queries=[form_data.query], queries=[form_data.query],
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, user=user query, prefix=prefix, user=user
), ),
k=form_data.k if form_data.k else request.app.state.config.TOP_K, k=form_data.k if form_data.k else request.app.state.config.TOP_K,
) )
@ -1644,7 +1664,11 @@ if ENV == "dev":
@router.get("/ef/{text}") @router.get("/ef/{text}")
async def get_embeddings(request: Request, text: Optional[str] = "Hello World!"): async def get_embeddings(request: Request, text: Optional[str] = "Hello World!"):
return {"result": request.app.state.EMBEDDING_FUNCTION(text)} return {
"result": request.app.state.EMBEDDING_FUNCTION(
text, prefix=RAG_EMBEDDING_QUERY_PREFIX
)
}
class BatchProcessFilesForm(BaseModel): class BatchProcessFilesForm(BaseModel):

View file

@ -2,6 +2,7 @@ import logging
from typing import Optional from typing import Optional
from open_webui.models.auths import Auths from open_webui.models.auths import Auths
from open_webui.models.groups import Groups
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.users import ( from open_webui.models.users import (
UserModel, UserModel,
@ -17,7 +18,10 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel from pydantic import BaseModel
from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user
from open_webui.utils.access_control import get_permissions
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"]) log.setLevel(SRC_LOG_LEVELS["MODELS"])
@ -45,7 +49,7 @@ async def get_users(
@router.get("/groups") @router.get("/groups")
async def get_user_groups(user=Depends(get_verified_user)): async def get_user_groups(user=Depends(get_verified_user)):
return Users.get_user_groups(user.id) return Groups.get_groups_by_member_id(user.id)
############################ ############################
@ -54,8 +58,12 @@ async def get_user_groups(user=Depends(get_verified_user)):
@router.get("/permissions") @router.get("/permissions")
async def get_user_permissisions(user=Depends(get_verified_user)): async def get_user_permissisions(request: Request, user=Depends(get_verified_user)):
return Users.get_user_groups(user.id) user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
)
return user_permissions
############################ ############################
@ -68,12 +76,20 @@ class WorkspacePermissions(BaseModel):
tools: bool = False tools: bool = False
class SharingPermissions(BaseModel):
public_models: bool = True
public_knowledge: bool = True
public_prompts: bool = True
public_tools: bool = True
class ChatPermissions(BaseModel): class ChatPermissions(BaseModel):
controls: bool = True controls: bool = True
file_upload: bool = True file_upload: bool = True
delete: bool = True delete: bool = True
edit: bool = True edit: bool = True
temporary: bool = True temporary: bool = True
temporary_enforced: bool = False
class FeaturesPermissions(BaseModel): class FeaturesPermissions(BaseModel):
@ -84,16 +100,20 @@ class FeaturesPermissions(BaseModel):
class UserPermissions(BaseModel): class UserPermissions(BaseModel):
workspace: WorkspacePermissions workspace: WorkspacePermissions
sharing: SharingPermissions
chat: ChatPermissions chat: ChatPermissions
features: FeaturesPermissions features: FeaturesPermissions
@router.get("/default/permissions", response_model=UserPermissions) @router.get("/default/permissions", response_model=UserPermissions)
async def get_user_permissions(request: Request, user=Depends(get_admin_user)): async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
return { return {
"workspace": WorkspacePermissions( "workspace": WorkspacePermissions(
**request.app.state.config.USER_PERMISSIONS.get("workspace", {}) **request.app.state.config.USER_PERMISSIONS.get("workspace", {})
), ),
"sharing": SharingPermissions(
**request.app.state.config.USER_PERMISSIONS.get("sharing", {})
),
"chat": ChatPermissions( "chat": ChatPermissions(
**request.app.state.config.USER_PERMISSIONS.get("chat", {}) **request.app.state.config.USER_PERMISSIONS.get("chat", {})
), ),
@ -104,7 +124,7 @@ async def get_user_permissions(request: Request, user=Depends(get_admin_user)):
@router.post("/default/permissions") @router.post("/default/permissions")
async def update_user_permissions( async def update_default_user_permissions(
request: Request, form_data: UserPermissions, user=Depends(get_admin_user) request: Request, form_data: UserPermissions, user=Depends(get_admin_user)
): ):
request.app.state.config.USER_PERMISSIONS = form_data.model_dump() request.app.state.config.USER_PERMISSIONS = form_data.model_dump()

View file

@ -3,16 +3,24 @@ import socketio
import logging import logging
import sys import sys
import time import time
from redis import asyncio as aioredis
from open_webui.models.users import Users, UserNameResponse from open_webui.models.users import Users, UserNameResponse
from open_webui.models.channels import Channels from open_webui.models.channels import Channels
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.utils.redis import (
parse_redis_sentinel_url,
get_sentinels_from_env,
AsyncRedisSentinelManager,
)
from open_webui.env import ( from open_webui.env import (
ENABLE_WEBSOCKET_SUPPORT, ENABLE_WEBSOCKET_SUPPORT,
WEBSOCKET_MANAGER, WEBSOCKET_MANAGER,
WEBSOCKET_REDIS_URL, WEBSOCKET_REDIS_URL,
WEBSOCKET_REDIS_LOCK_TIMEOUT, WEBSOCKET_REDIS_LOCK_TIMEOUT,
WEBSOCKET_SENTINEL_PORT,
WEBSOCKET_SENTINEL_HOSTS,
) )
from open_webui.utils.auth import decode_token from open_webui.utils.auth import decode_token
from open_webui.socket.utils import RedisDict, RedisLock from open_webui.socket.utils import RedisDict, RedisLock
@ -29,7 +37,19 @@ log.setLevel(SRC_LOG_LEVELS["SOCKET"])
if WEBSOCKET_MANAGER == "redis": if WEBSOCKET_MANAGER == "redis":
mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL) if WEBSOCKET_SENTINEL_HOSTS:
redis_config = parse_redis_sentinel_url(WEBSOCKET_REDIS_URL)
mgr = AsyncRedisSentinelManager(
WEBSOCKET_SENTINEL_HOSTS.split(","),
sentinel_port=int(WEBSOCKET_SENTINEL_PORT),
redis_port=redis_config["port"],
service=redis_config["service"],
db=redis_config["db"],
username=redis_config["username"],
password=redis_config["password"],
)
else:
mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL)
sio = socketio.AsyncServer( sio = socketio.AsyncServer(
cors_allowed_origins=[], cors_allowed_origins=[],
async_mode="asgi", async_mode="asgi",
@ -55,14 +75,30 @@ TIMEOUT_DURATION = 3
if WEBSOCKET_MANAGER == "redis": if WEBSOCKET_MANAGER == "redis":
log.debug("Using Redis to manage websockets.") log.debug("Using Redis to manage websockets.")
SESSION_POOL = RedisDict("open-webui:session_pool", redis_url=WEBSOCKET_REDIS_URL) redis_sentinels = get_sentinels_from_env(
USER_POOL = RedisDict("open-webui:user_pool", redis_url=WEBSOCKET_REDIS_URL) WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
USAGE_POOL = RedisDict("open-webui:usage_pool", redis_url=WEBSOCKET_REDIS_URL) )
SESSION_POOL = RedisDict(
"open-webui:session_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
)
USER_POOL = RedisDict(
"open-webui:user_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
)
USAGE_POOL = RedisDict(
"open-webui:usage_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
)
clean_up_lock = RedisLock( clean_up_lock = RedisLock(
redis_url=WEBSOCKET_REDIS_URL, redis_url=WEBSOCKET_REDIS_URL,
lock_name="usage_cleanup_lock", lock_name="usage_cleanup_lock",
timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT, timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT,
redis_sentinels=redis_sentinels,
) )
aquire_func = clean_up_lock.aquire_lock aquire_func = clean_up_lock.aquire_lock
renew_func = clean_up_lock.renew_lock renew_func = clean_up_lock.renew_lock
@ -269,11 +305,19 @@ async def disconnect(sid):
# print(f"Unknown session ID {sid} disconnected") # print(f"Unknown session ID {sid} disconnected")
def get_event_emitter(request_info): def get_event_emitter(request_info, update_db=True):
async def __event_emitter__(event_data): async def __event_emitter__(event_data):
user_id = request_info["user_id"] user_id = request_info["user_id"]
session_ids = list( session_ids = list(
set(USER_POOL.get(user_id, []) + [request_info["session_id"]]) set(
USER_POOL.get(user_id, [])
+ (
[request_info.get("session_id")]
if request_info.get("session_id")
else []
)
)
) )
for session_id in session_ids: for session_id in session_ids:
@ -287,40 +331,41 @@ def get_event_emitter(request_info):
to=session_id, to=session_id,
) )
if "type" in event_data and event_data["type"] == "status": if update_db:
Chats.add_message_status_to_chat_by_id_and_message_id( if "type" in event_data and event_data["type"] == "status":
request_info["chat_id"], Chats.add_message_status_to_chat_by_id_and_message_id(
request_info["message_id"], request_info["chat_id"],
event_data.get("data", {}), request_info["message_id"],
) event_data.get("data", {}),
)
if "type" in event_data and event_data["type"] == "message": if "type" in event_data and event_data["type"] == "message":
message = Chats.get_message_by_id_and_message_id( message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"], request_info["chat_id"],
request_info["message_id"], request_info["message_id"],
) )
content = message.get("content", "") content = message.get("content", "")
content += event_data.get("data", {}).get("content", "") content += event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id( Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"], request_info["chat_id"],
request_info["message_id"], request_info["message_id"],
{ {
"content": content, "content": content,
}, },
) )
if "type" in event_data and event_data["type"] == "replace": if "type" in event_data and event_data["type"] == "replace":
content = event_data.get("data", {}).get("content", "") content = event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id( Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"], request_info["chat_id"],
request_info["message_id"], request_info["message_id"],
{ {
"content": content, "content": content,
}, },
) )
return __event_emitter__ return __event_emitter__

View file

@ -1,15 +1,17 @@
import json import json
import redis
import uuid import uuid
from open_webui.utils.redis import get_redis_connection
class RedisLock: class RedisLock:
def __init__(self, redis_url, lock_name, timeout_secs): def __init__(self, redis_url, lock_name, timeout_secs, redis_sentinels=[]):
self.lock_name = lock_name self.lock_name = lock_name
self.lock_id = str(uuid.uuid4()) self.lock_id = str(uuid.uuid4())
self.timeout_secs = timeout_secs self.timeout_secs = timeout_secs
self.lock_obtained = False self.lock_obtained = False
self.redis = redis.Redis.from_url(redis_url, decode_responses=True) self.redis = get_redis_connection(
redis_url, redis_sentinels, decode_responses=True
)
def aquire_lock(self): def aquire_lock(self):
# nx=True will only set this key if it _hasn't_ already been set # nx=True will only set this key if it _hasn't_ already been set
@ -31,9 +33,11 @@ class RedisLock:
class RedisDict: class RedisDict:
def __init__(self, name, redis_url): def __init__(self, name, redis_url, redis_sentinels=[]):
self.name = name self.name = name
self.redis = redis.Redis.from_url(redis_url, decode_responses=True) self.redis = get_redis_connection(
redis_url, redis_sentinels, decode_responses=True
)
def __setitem__(self, key, value): def __setitem__(self, key, value):
serialized_value = json.dumps(value) serialized_value = json.dumps(value)

View file

@ -101,11 +101,12 @@ async def process_filter_functions(
form_data = handler(**params) form_data = handler(**params)
except Exception as e: except Exception as e:
log.exception(f"Error in {filter_type} handler {filter_id}: {e}") log.debug(f"Error in {filter_type} handler {filter_id}: {e}")
raise e raise e
# Handle file cleanup for inlet # Handle file cleanup for inlet
if skip_files and "files" in form_data.get("metadata", {}): if skip_files and "files" in form_data.get("metadata", {}):
del form_data["files"]
del form_data["metadata"]["files"] del form_data["metadata"]["files"]
return form_data, {} return form_data, {}

View file

@ -18,9 +18,7 @@ from uuid import uuid4
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from fastapi import Request from fastapi import Request, HTTPException
from fastapi import BackgroundTasks
from starlette.responses import Response, StreamingResponse from starlette.responses import Response, StreamingResponse
@ -100,7 +98,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
async def chat_completion_tools_handler( async def chat_completion_tools_handler(
request: Request, body: dict, user: UserModel, models, tools request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
) -> tuple[dict, dict]: ) -> tuple[dict, dict]:
async def get_content_from_response(response) -> Optional[str]: async def get_content_from_response(response) -> Optional[str]:
content = None content = None
@ -135,6 +133,9 @@ async def chat_completion_tools_handler(
"metadata": {"task": str(TASKS.FUNCTION_CALLING)}, "metadata": {"task": str(TASKS.FUNCTION_CALLING)},
} }
event_caller = extra_params["__event_call__"]
metadata = extra_params["__metadata__"]
task_model_id = get_task_model_id( task_model_id = get_task_model_id(
body["model"], body["model"],
request.app.state.config.TASK_MODEL, request.app.state.config.TASK_MODEL,
@ -156,7 +157,6 @@ async def chat_completion_tools_handler(
tools_function_calling_prompt = tools_function_calling_generation_template( tools_function_calling_prompt = tools_function_calling_generation_template(
template, tools_specs template, tools_specs
) )
log.info(f"{tools_function_calling_prompt=}")
payload = get_tools_function_calling_payload( payload = get_tools_function_calling_payload(
body["messages"], task_model_id, tools_function_calling_prompt body["messages"], task_model_id, tools_function_calling_prompt
) )
@ -189,34 +189,63 @@ async def chat_completion_tools_handler(
tool_function_params = tool_call.get("parameters", {}) tool_function_params = tool_call.get("parameters", {})
try: try:
required_params = ( tool = tools[tool_function_name]
tools[tool_function_name]
.get("spec", {}) spec = tool.get("spec", {})
.get("parameters", {}) allowed_params = (
.get("required", []) spec.get("parameters", {}).get("properties", {}).keys()
) )
tool_function = tools[tool_function_name]["callable"]
tool_function_params = { tool_function_params = {
k: v k: v
for k, v in tool_function_params.items() for k, v in tool_function_params.items()
if k in required_params if k in allowed_params
} }
tool_output = await tool_function(**tool_function_params)
if tool.get("direct", False):
tool_result = await event_caller(
{
"type": "execute:tool",
"data": {
"id": str(uuid4()),
"name": tool_function_name,
"params": tool_function_params,
"server": tool.get("server", {}),
"session_id": metadata.get("session_id", None),
},
}
)
else:
tool_function = tool["callable"]
tool_result = await tool_function(**tool_function_params)
except Exception as e: except Exception as e:
tool_output = str(e) tool_result = str(e)
if isinstance(tool_result, dict) or isinstance(tool_result, list):
tool_result = json.dumps(tool_result, indent=2)
if isinstance(tool_result, str):
tool = tools[tool_function_name]
tool_id = tool.get("toolkit_id", "")
if tool.get("citation", False) or tool.get("direct", False):
if isinstance(tool_output, str):
if tools[tool_function_name]["citation"]:
sources.append( sources.append(
{ {
"source": { "source": {
"name": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" "name": (
f"TOOL:" + f"{tool_id}/{tool_function_name}"
if tool_id
else f"{tool_function_name}"
),
}, },
"document": [tool_output], "document": [tool_result],
"metadata": [ "metadata": [
{ {
"source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" "source": (
f"TOOL:" + f"{tool_id}/{tool_function_name}"
if tool_id
else f"{tool_function_name}"
)
} }
], ],
} }
@ -225,16 +254,20 @@ async def chat_completion_tools_handler(
sources.append( sources.append(
{ {
"source": {}, "source": {},
"document": [tool_output], "document": [tool_result],
"metadata": [ "metadata": [
{ {
"source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" "source": (
f"TOOL:" + f"{tool_id}/{tool_function_name}"
if tool_id
else f"{tool_function_name}"
)
} }
], ],
} }
) )
if tools[tool_function_name]["file_handler"]: if tools[tool_function_name].get("file_handler", False):
skip_files = True skip_files = True
# check if "tool_calls" in result # check if "tool_calls" in result
@ -245,10 +278,10 @@ async def chat_completion_tools_handler(
await tool_call_handler(result) await tool_call_handler(result)
except Exception as e: except Exception as e:
log.exception(f"Error: {e}") log.debug(f"Error: {e}")
content = None content = None
except Exception as e: except Exception as e:
log.exception(f"Error: {e}") log.debug(f"Error: {e}")
content = None content = None
log.debug(f"tool_contexts: {sources}") log.debug(f"tool_contexts: {sources}")
@ -562,11 +595,12 @@ async def chat_completion_files_handler(
request=request, request=request,
files=files, files=files,
queries=queries, queries=queries,
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
query, user=user query, prefix=prefix, user=user
), ),
k=request.app.state.config.TOP_K, k=request.app.state.config.TOP_K,
reranking_function=request.app.state.rf, reranking_function=request.app.state.rf,
k_reranker=request.app.state.config.TOP_K_RERANKER,
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, full_context=request.app.state.config.RAG_FULL_CONTEXT,
@ -766,12 +800,18 @@ async def process_chat_payload(request, form_data, user, metadata, model):
} }
form_data["metadata"] = metadata form_data["metadata"] = metadata
# Server side tools
tool_ids = metadata.get("tool_ids", None) tool_ids = metadata.get("tool_ids", None)
# Client side tools
tool_servers = metadata.get("tool_servers", None)
log.debug(f"{tool_ids=}") log.debug(f"{tool_ids=}")
log.debug(f"{tool_servers=}")
tools_dict = {}
if tool_ids: if tool_ids:
# If tool_ids field is present, then get the tools tools_dict = get_tools(
tools = get_tools(
request, request,
tool_ids, tool_ids,
user, user,
@ -782,20 +822,31 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"__files__": metadata.get("files", []), "__files__": metadata.get("files", []),
}, },
) )
log.info(f"{tools=}")
if tool_servers:
for tool_server in tool_servers:
tool_specs = tool_server.pop("specs", [])
for tool in tool_specs:
tools_dict[tool["name"]] = {
"spec": tool,
"direct": True,
"server": tool_server,
}
if tools_dict:
if metadata.get("function_calling") == "native": if metadata.get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler # If the function calling is native, then call the tools function calling handler
metadata["tools"] = tools metadata["tools"] = tools_dict
form_data["tools"] = [ form_data["tools"] = [
{"type": "function", "function": tool.get("spec", {})} {"type": "function", "function": tool.get("spec", {})}
for tool in tools.values() for tool in tools_dict.values()
] ]
else: else:
# If the function calling is not native, then call the tools function calling handler # If the function calling is not native, then call the tools function calling handler
try: try:
form_data, flags = await chat_completion_tools_handler( form_data, flags = await chat_completion_tools_handler(
request, form_data, user, models, tools request, form_data, extra_params, user, models, tools_dict
) )
sources.extend(flags.get("sources", [])) sources.extend(flags.get("sources", []))
@ -814,7 +865,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
for source_idx, source in enumerate(sources): for source_idx, source in enumerate(sources):
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 + 1}</source_id><source_context>{doc_context}</source_context></source>\n"
context_string = context_string.strip() context_string = context_string.strip()
prompt = get_last_user_message(form_data["messages"]) prompt = get_last_user_message(form_data["messages"])
@ -991,6 +1042,16 @@ async def process_chat_response(
# Non-streaming response # Non-streaming response
if not isinstance(response, StreamingResponse): if not isinstance(response, StreamingResponse):
if event_emitter: if event_emitter:
if "error" in response:
error = response["error"].get("detail", response["error"])
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": error},
},
)
if "selected_model_id" in response: if "selected_model_id" in response:
Chats.upsert_message_to_chat_by_id_and_message_id( Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"], metadata["chat_id"],
@ -1000,7 +1061,8 @@ async def process_chat_response(
}, },
) )
if response.get("choices", [])[0].get("message", {}).get("content"): choices = response.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
content = response["choices"][0]["message"]["content"] content = response["choices"][0]["message"]["content"]
if content: if content:
@ -1081,8 +1143,6 @@ async def process_chat_response(
for filter_id in get_sorted_filter_ids(model) for filter_id in get_sorted_filter_ids(model)
] ]
print(f"{filter_functions=}")
# 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.
@ -1121,36 +1181,51 @@ async def process_chat_response(
elif block["type"] == "tool_calls": elif block["type"] == "tool_calls":
attributes = block.get("attributes", {}) attributes = block.get("attributes", {})
block_content = block.get("content", []) tool_calls = block.get("content", [])
results = block.get("results", []) results = block.get("results", [])
if results: if results:
result_display_content = "" tool_calls_display_content = ""
for tool_call in tool_calls:
for result in results: tool_call_id = tool_call.get("id", "")
tool_call_id = result.get("tool_call_id", "") tool_name = tool_call.get("function", {}).get(
tool_name = "" "name", ""
)
tool_arguments = tool_call.get("function", {}).get(
"arguments", ""
)
for tool_call in block_content: tool_result = None
if tool_call.get("id", "") == tool_call_id: for result in results:
tool_name = tool_call.get("function", {}).get( if tool_call_id == result.get("tool_call_id", ""):
"name", "" tool_result = result.get("content", None)
)
break break
result_display_content = f"{result_display_content}\n> {tool_name}: {result.get('content', '')}" if tool_result:
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result))}">\n<summary>Tool Executed</summary>\n</details>'
else:
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
if not raw: if not raw:
content = f'{content}\n<details type="tool_calls" done="true" content="{html.escape(json.dumps(block_content))}" results="{html.escape(json.dumps(results))}">\n<summary>Tool Executed</summary>\n{result_display_content}\n</details>\n' content = f"{content}\n{tool_calls_display_content}\n\n"
else: else:
tool_calls_display_content = "" tool_calls_display_content = ""
for tool_call in block_content: for tool_call in tool_calls:
tool_calls_display_content = f"{tool_calls_display_content}\n> Executing {tool_call.get('function', {}).get('name', '')}" tool_call_id = tool_call.get("id", "")
tool_name = tool_call.get("function", {}).get(
"name", ""
)
tool_arguments = tool_call.get("function", {}).get(
"arguments", ""
)
tool_calls_display_content = f'{tool_calls_display_content}\n<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>'
if not raw: if not raw:
content = f'{content}\n<details type="tool_calls" done="false" content="{html.escape(json.dumps(block_content))}">\n<summary>Tool Executing...</summary>\n{tool_calls_display_content}\n</details>\n' content = f"{content}\n{tool_calls_display_content}\n\n"
elif block["type"] == "reasoning": elif block["type"] == "reasoning":
reasoning_display_content = "\n".join( reasoning_display_content = "\n".join(
@ -1507,6 +1582,16 @@ async def process_chat_response(
else: else:
choices = data.get("choices", []) choices = data.get("choices", [])
if not choices: if not choices:
error = data.get("error", {})
if error:
await event_emitter(
{
"type": "chat:completion",
"data": {
"error": error,
},
}
)
usage = data.get("usage", {}) usage = data.get("usage", {})
if usage: if usage:
await event_emitter( await event_emitter(
@ -1562,7 +1647,9 @@ async def process_chat_response(
value = delta.get("content") value = delta.get("content")
reasoning_content = delta.get("reasoning_content") reasoning_content = delta.get(
"reasoning_content"
) or delta.get("reasoning")
if reasoning_content: if reasoning_content:
if ( if (
not content_blocks not content_blocks
@ -1757,6 +1844,15 @@ async def process_chat_response(
) )
except Exception as e: except Exception as e:
log.debug(e) log.debug(e)
# Fallback to JSON parsing
try:
tool_function_params = json.loads(
tool_call.get("function", {}).get("arguments", "{}")
)
except Exception as e:
log.debug(
f"Error parsing tool call arguments: {tool_call.get('function', {}).get('arguments', '{}')}"
)
tool_result = None tool_result = None
@ -1765,21 +1861,48 @@ async def process_chat_response(
spec = tool.get("spec", {}) spec = tool.get("spec", {})
try: try:
required_params = spec.get("parameters", {}).get( allowed_params = (
"required", [] spec.get("parameters", {})
.get("properties", {})
.keys()
) )
tool_function = tool["callable"]
tool_function_params = { tool_function_params = {
k: v k: v
for k, v in tool_function_params.items() for k, v in tool_function_params.items()
if k in required_params if k in allowed_params
} }
tool_result = await tool_function(
**tool_function_params if tool.get("direct", False):
) tool_result = await event_caller(
{
"type": "execute:tool",
"data": {
"id": str(uuid4()),
"name": tool_name,
"params": tool_function_params,
"server": tool.get("server", {}),
"session_id": metadata.get(
"session_id", None
),
},
}
)
else:
tool_function = tool["callable"]
tool_result = await tool_function(
**tool_function_params
)
except Exception as e: except Exception as e:
tool_result = str(e) tool_result = str(e)
if isinstance(tool_result, dict) or isinstance(
tool_result, list
):
tool_result = json.dumps(tool_result, indent=2)
results.append( results.append(
{ {
"tool_call_id": tool_call_id, "tool_call_id": tool_call_id,
@ -1982,11 +2105,6 @@ async def process_chat_response(
} }
) )
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(
request, request,

View file

@ -49,6 +49,7 @@ async def get_all_base_models(request: Request, user: UserModel = None):
"created": int(time.time()), "created": int(time.time()),
"owned_by": "ollama", "owned_by": "ollama",
"ollama": model, "ollama": model,
"tags": model.get("tags", []),
} }
for model in ollama_models["models"] for model in ollama_models["models"]
] ]

View file

@ -94,7 +94,7 @@ class OAuthManager:
oauth_claim = auth_manager_config.OAUTH_ROLES_CLAIM oauth_claim = auth_manager_config.OAUTH_ROLES_CLAIM
oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES
oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES
oauth_roles = None oauth_roles = []
# Default/fallback role if no matching roles are found # Default/fallback role if no matching roles are found
role = auth_manager_config.DEFAULT_USER_ROLE role = auth_manager_config.DEFAULT_USER_ROLE
@ -104,7 +104,7 @@ class OAuthManager:
nested_claims = oauth_claim.split(".") nested_claims = oauth_claim.split(".")
for nested_claim in nested_claims: for nested_claim in nested_claims:
claim_data = claim_data.get(nested_claim, {}) claim_data = claim_data.get(nested_claim, {})
oauth_roles = claim_data if isinstance(claim_data, list) else None oauth_roles = claim_data if isinstance(claim_data, list) else []
log.debug(f"Oauth Roles claim: {oauth_claim}") log.debug(f"Oauth Roles claim: {oauth_claim}")
log.debug(f"User roles from oauth: {oauth_roles}") log.debug(f"User roles from oauth: {oauth_roles}")
@ -140,6 +140,7 @@ 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 = []
# Nested claim search for groups claim # Nested claim search for groups claim
if oauth_claim: if oauth_claim:
claim_data = user_data claim_data = user_data
@ -160,7 +161,7 @@ class OAuthManager:
# Remove groups that user is no longer a part of # Remove groups that user is no longer a part of
for group_model in user_current_groups: for group_model in user_current_groups:
if group_model.name not in user_oauth_groups: if user_oauth_groups and group_model.name not in user_oauth_groups:
# Remove group from user # Remove group from user
log.debug( log.debug(
f"Removing user from group {group_model.name} as it is no longer in their oauth groups" f"Removing user from group {group_model.name} as it is no longer in their oauth groups"
@ -186,8 +187,10 @@ class OAuthManager:
# Add user to new groups # Add user to new groups
for group_model in all_available_groups: for group_model in all_available_groups:
if group_model.name in user_oauth_groups and not any( if (
gm.name == group_model.name for gm in user_current_groups user_oauth_groups
and group_model.name in user_oauth_groups
and not any(gm.name == group_model.name for gm in user_current_groups)
): ):
# Add user to group # Add user to group
log.debug( log.debug(

View file

@ -63,6 +63,7 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
"seed": lambda x: x, "seed": lambda x: x,
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x], "stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
"logit_bias": lambda x: x, "logit_bias": lambda x: x,
"response_format": dict,
} }
return apply_model_params_to_body(params, form_data, mappings) return apply_model_params_to_body(params, form_data, mappings)
@ -110,6 +111,15 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
"num_thread": int, "num_thread": int,
} }
# Extract keep_alive from options if it exists
if "options" in form_data and "keep_alive" in form_data["options"]:
form_data["keep_alive"] = form_data["options"]["keep_alive"]
del form_data["options"]["keep_alive"]
if "options" in form_data and "format" in form_data["options"]:
form_data["format"] = form_data["options"]["format"]
del form_data["options"]["format"]
return apply_model_params_to_body(params, form_data, mappings) return apply_model_params_to_body(params, form_data, mappings)
@ -231,6 +241,11 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
"system" "system"
] # To prevent Ollama warning of invalid option provided ] # To prevent Ollama warning of invalid option provided
# Extract keep_alive from options if it exists
if "keep_alive" in ollama_options:
ollama_payload["keep_alive"] = ollama_options["keep_alive"]
del ollama_options["keep_alive"]
# If there is the "stop" parameter in the openai_payload, remap it to the ollama_payload.options # If there is the "stop" parameter in the openai_payload, remap it to the ollama_payload.options
if "stop" in openai_payload: if "stop" in openai_payload:
ollama_options = ollama_payload.get("options", {}) ollama_options = ollama_payload.get("options", {})
@ -240,4 +255,13 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
if "metadata" in openai_payload: if "metadata" in openai_payload:
ollama_payload["metadata"] = openai_payload["metadata"] ollama_payload["metadata"] = openai_payload["metadata"]
if "response_format" in openai_payload:
response_format = openai_payload["response_format"]
format_type = response_format.get("type", None)
schema = response_format.get(format_type, None)
if schema:
format = schema.get("schema", None)
ollama_payload["format"] = format
return ollama_payload return ollama_payload

View file

@ -7,7 +7,7 @@ import types
import tempfile import tempfile
import logging import logging
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS, PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS
from open_webui.models.functions import Functions from open_webui.models.functions import Functions
from open_webui.models.tools import Tools from open_webui.models.tools import Tools
@ -165,15 +165,19 @@ def load_function_module_by_id(function_id, content=None):
os.unlink(temp_file.name) os.unlink(temp_file.name)
def install_frontmatter_requirements(requirements): def install_frontmatter_requirements(requirements: str):
if requirements: if requirements:
try: try:
req_list = [req.strip() for req in requirements.split(",")] req_list = [req.strip() for req in requirements.split(",")]
for req in req_list: log.info(f"Installing requirements: {' '.join(req_list)}")
log.info(f"Installing requirement: {req}") subprocess.check_call(
subprocess.check_call([sys.executable, "-m", "pip", "install", req]) [sys.executable, "-m", "pip", "install"]
+ PIP_OPTIONS
+ req_list
+ PIP_PACKAGE_INDEX_OPTIONS
)
except Exception as e: except Exception as e:
log.error(f"Error installing package: {req}") log.error(f"Error installing packages: {' '.join(req_list)}")
raise e raise e
else: else:

View file

@ -0,0 +1,109 @@
import socketio
import redis
from redis import asyncio as aioredis
from urllib.parse import urlparse
def parse_redis_sentinel_url(redis_url):
parsed_url = urlparse(redis_url)
if parsed_url.scheme != "redis":
raise ValueError("Invalid Redis URL scheme. Must be 'redis'.")
return {
"username": parsed_url.username or None,
"password": parsed_url.password or None,
"service": parsed_url.hostname or "mymaster",
"port": parsed_url.port or 6379,
"db": int(parsed_url.path.lstrip("/") or 0),
}
def get_redis_connection(redis_url, redis_sentinels, decode_responses=True):
if redis_sentinels:
redis_config = parse_redis_sentinel_url(redis_url)
sentinel = redis.sentinel.Sentinel(
redis_sentinels,
port=redis_config["port"],
db=redis_config["db"],
username=redis_config["username"],
password=redis_config["password"],
decode_responses=decode_responses,
)
# Get a master connection from Sentinel
return sentinel.master_for(redis_config["service"])
else:
# Standard Redis connection
return redis.Redis.from_url(redis_url, decode_responses=decode_responses)
def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env):
if sentinel_hosts_env:
sentinel_hosts = sentinel_hosts_env.split(",")
sentinel_port = int(sentinel_port_env)
return [(host, sentinel_port) for host in sentinel_hosts]
return []
class AsyncRedisSentinelManager(socketio.AsyncRedisManager):
def __init__(
self,
sentinel_hosts,
sentinel_port=26379,
redis_port=6379,
service="mymaster",
db=0,
username=None,
password=None,
channel="socketio",
write_only=False,
logger=None,
redis_options=None,
):
"""
Initialize the Redis Sentinel Manager.
This implementation mostly replicates the __init__ of AsyncRedisManager and
overrides _redis_connect() with a version that uses Redis Sentinel
:param sentinel_hosts: List of Sentinel hosts
:param sentinel_port: Sentinel Port
:param redis_port: Redis Port (currently unsupported by aioredis!)
:param service: Master service name in Sentinel
:param db: Redis database to use
:param username: Redis username (if any) (currently unsupported by aioredis!)
:param password: Redis password (if any)
:param channel: The channel name on which the server sends and receives
notifications. Must be the same in all the servers.
:param write_only: If set to ``True``, only initialize to emit events. The
default of ``False`` initializes the class for emitting
and receiving.
:param redis_options: additional keyword arguments to be passed to
``aioredis.from_url()``.
"""
self._sentinels = [(host, sentinel_port) for host in sentinel_hosts]
self._redis_port = redis_port
self._service = service
self._db = db
self._username = username
self._password = password
self._channel = channel
self.redis_options = redis_options or {}
# connect and call grandparent constructor
self._redis_connect()
super(socketio.AsyncRedisManager, self).__init__(
channel=channel, write_only=write_only, logger=logger
)
def _redis_connect(self):
"""Establish connections to Redis through Sentinel."""
sentinel = aioredis.sentinel.Sentinel(
self._sentinels,
port=self._redis_port,
db=self._db,
password=self._password,
**self.redis_options,
)
self.redis = sentinel.master_for(self._service)
self.pubsub = self.redis.pubsub(ignore_subscribe_messages=True)

View file

@ -0,0 +1,26 @@
from opentelemetry.semconv.trace import SpanAttributes as _SpanAttributes
# Span Tags
SPAN_DB_TYPE = "mysql"
SPAN_REDIS_TYPE = "redis"
SPAN_DURATION = "duration"
SPAN_SQL_STR = "sql"
SPAN_SQL_EXPLAIN = "explain"
SPAN_ERROR_TYPE = "error"
class SpanAttributes(_SpanAttributes):
"""
Span Attributes
"""
DB_INSTANCE = "db.instance"
DB_TYPE = "db.type"
DB_IP = "db.ip"
DB_PORT = "db.port"
ERROR_KIND = "error.kind"
ERROR_OBJECT = "error.object"
ERROR_MESSAGE = "error.message"
RESULT_CODE = "result.code"
RESULT_MESSAGE = "result.message"
RESULT_ERRORS = "result.errors"

View file

@ -0,0 +1,31 @@
import threading
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export import BatchSpanProcessor
class LazyBatchSpanProcessor(BatchSpanProcessor):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.done = True
with self.condition:
self.condition.notify_all()
self.worker_thread.join()
self.done = False
self.worker_thread = None
def on_end(self, span: ReadableSpan) -> None:
if self.worker_thread is None:
self.worker_thread = threading.Thread(
name=self.__class__.__name__, target=self.worker, daemon=True
)
self.worker_thread.start()
super().on_end(span)
def shutdown(self) -> None:
self.done = True
with self.condition:
self.condition.notify_all()
if self.worker_thread:
self.worker_thread.join()
self.span_exporter.shutdown()

View file

@ -0,0 +1,202 @@
import logging
import traceback
from typing import Collection, Union
from aiohttp import (
TraceRequestStartParams,
TraceRequestEndParams,
TraceRequestExceptionParams,
)
from chromadb.telemetry.opentelemetry.fastapi import instrument_fastapi
from fastapi import FastAPI
from opentelemetry.instrumentation.httpx import (
HTTPXClientInstrumentor,
RequestInfo,
ResponseInfo,
)
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor
from opentelemetry.trace import Span, StatusCode
from redis import Redis
from requests import PreparedRequest, Response
from sqlalchemy import Engine
from fastapi import status
from open_webui.utils.telemetry.constants import SPAN_REDIS_TYPE, SpanAttributes
from open_webui.env import SRC_LOG_LEVELS
logger = logging.getLogger(__name__)
logger.setLevel(SRC_LOG_LEVELS["MAIN"])
def requests_hook(span: Span, request: PreparedRequest):
"""
Http Request Hook
"""
span.update_name(f"{request.method} {request.url}")
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: request.url,
SpanAttributes.HTTP_METHOD: request.method,
}
)
def response_hook(span: Span, request: PreparedRequest, response: Response):
"""
HTTP Response Hook
"""
span.set_attributes(
attributes={
SpanAttributes.HTTP_STATUS_CODE: response.status_code,
}
)
span.set_status(StatusCode.ERROR if response.status_code >= 400 else StatusCode.OK)
def redis_request_hook(span: Span, instance: Redis, args, kwargs):
"""
Redis Request Hook
"""
try:
connection_kwargs: dict = instance.connection_pool.connection_kwargs
host = connection_kwargs.get("host")
port = connection_kwargs.get("port")
db = connection_kwargs.get("db")
span.set_attributes(
{
SpanAttributes.DB_INSTANCE: f"{host}/{db}",
SpanAttributes.DB_NAME: f"{host}/{db}",
SpanAttributes.DB_TYPE: SPAN_REDIS_TYPE,
SpanAttributes.DB_PORT: port,
SpanAttributes.DB_IP: host,
SpanAttributes.DB_STATEMENT: " ".join([str(i) for i in args]),
SpanAttributes.DB_OPERATION: str(args[0]),
}
)
except Exception: # pylint: disable=W0718
logger.error(traceback.format_exc())
def httpx_request_hook(span: Span, request: RequestInfo):
"""
HTTPX Request Hook
"""
span.update_name(f"{request.method.decode()} {str(request.url)}")
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: str(request.url),
SpanAttributes.HTTP_METHOD: request.method.decode(),
}
)
def httpx_response_hook(span: Span, request: RequestInfo, response: ResponseInfo):
"""
HTTPX Response Hook
"""
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.status_code)
span.set_status(
StatusCode.ERROR
if response.status_code >= status.HTTP_400_BAD_REQUEST
else StatusCode.OK
)
async def httpx_async_request_hook(span: Span, request: RequestInfo):
"""
Async Request Hook
"""
httpx_request_hook(span, request)
async def httpx_async_response_hook(
span: Span, request: RequestInfo, response: ResponseInfo
):
"""
Async Response Hook
"""
httpx_response_hook(span, request, response)
def aiohttp_request_hook(span: Span, request: TraceRequestStartParams):
"""
Aiohttp Request Hook
"""
span.update_name(f"{request.method} {str(request.url)}")
span.set_attributes(
attributes={
SpanAttributes.HTTP_URL: str(request.url),
SpanAttributes.HTTP_METHOD: request.method,
}
)
def aiohttp_response_hook(
span: Span, response: Union[TraceRequestExceptionParams, TraceRequestEndParams]
):
"""
Aiohttp Response Hook
"""
if isinstance(response, TraceRequestEndParams):
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.response.status)
span.set_status(
StatusCode.ERROR
if response.response.status >= status.HTTP_400_BAD_REQUEST
else StatusCode.OK
)
elif isinstance(response, TraceRequestExceptionParams):
span.set_status(StatusCode.ERROR)
span.set_attribute(SpanAttributes.ERROR_MESSAGE, str(response.exception))
class Instrumentor(BaseInstrumentor):
"""
Instrument OT
"""
def __init__(self, app: FastAPI, db_engine: Engine):
self.app = app
self.db_engine = db_engine
def instrumentation_dependencies(self) -> Collection[str]:
return []
def _instrument(self, **kwargs):
instrument_fastapi(app=self.app)
SQLAlchemyInstrumentor().instrument(engine=self.db_engine)
RedisInstrumentor().instrument(request_hook=redis_request_hook)
RequestsInstrumentor().instrument(
request_hook=requests_hook, response_hook=response_hook
)
LoggingInstrumentor().instrument()
HTTPXClientInstrumentor().instrument(
request_hook=httpx_request_hook,
response_hook=httpx_response_hook,
async_request_hook=httpx_async_request_hook,
async_response_hook=httpx_async_response_hook,
)
AioHttpClientInstrumentor().instrument(
request_hook=aiohttp_request_hook,
response_hook=aiohttp_response_hook,
)
def _uninstrument(self, **kwargs):
if getattr(self, "instrumentors", None) is None:
return
for instrumentor in self.instrumentors:
instrumentor.uninstrument()

View file

@ -0,0 +1,23 @@
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
from sqlalchemy import Engine
from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
from open_webui.utils.telemetry.instrumentors import Instrumentor
from open_webui.env import OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT
def setup(app: FastAPI, db_engine: Engine):
# set up trace
trace.set_tracer_provider(
TracerProvider(
resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
)
)
# otlp export
exporter = OTLPSpanExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT)
trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter))
Instrumentor(app=app, db_engine=db_engine).instrument()

View file

@ -1,6 +1,9 @@
import inspect import inspect
import logging import logging
import re import re
import inspect
import uuid
from typing import Any, Awaitable, Callable, get_type_hints from typing import Any, Awaitable, Callable, get_type_hints
from functools import update_wrapper, partial from functools import update_wrapper, partial
@ -88,10 +91,11 @@ def get_tools(
# TODO: This needs to be a pydantic model # TODO: This needs to be a pydantic model
tool_dict = { tool_dict = {
"toolkit_id": tool_id,
"callable": callable,
"spec": spec, "spec": spec,
"callable": callable,
"toolkit_id": tool_id,
"pydantic_model": function_to_pydantic_model(callable), "pydantic_model": function_to_pydantic_model(callable),
# Misc info
"file_handler": hasattr(module, "file_handler") and module.file_handler, "file_handler": hasattr(module, "file_handler") and module.file_handler,
"citation": hasattr(module, "citation") and module.citation, "citation": hasattr(module, "citation") and module.citation,
} }

View file

@ -37,13 +37,13 @@ asgiref==3.8.1
# AI libraries # AI libraries
openai openai
anthropic anthropic
google-generativeai==0.7.2 google-generativeai==0.8.4
tiktoken tiktoken
langchain==0.3.19 langchain==0.3.19
langchain-community==0.3.18 langchain-community==0.3.18
fake-useragent==1.5.1 fake-useragent==2.1.0
chromadb==0.6.2 chromadb==0.6.2
pymilvus==2.5.0 pymilvus==2.5.0
qdrant-client~=1.12.0 qdrant-client~=1.12.0
@ -78,6 +78,7 @@ sentencepiece
soundfile==0.13.1 soundfile==0.13.1
azure-ai-documentintelligence==1.0.0 azure-ai-documentintelligence==1.0.0
pillow==11.1.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
rank-bm25==0.2.2 rank-bm25==0.2.2
@ -118,3 +119,16 @@ ldap3==2.9.1
## Firecrawl ## Firecrawl
firecrawl-py==1.12.0 firecrawl-py==1.12.0
## Trace
opentelemetry-api==1.30.0
opentelemetry-sdk==1.30.0
opentelemetry-exporter-otlp==1.30.0
opentelemetry-instrumentation==0.51b0
opentelemetry-instrumentation-fastapi==0.51b0
opentelemetry-instrumentation-sqlalchemy==0.51b0
opentelemetry-instrumentation-redis==0.51b0
opentelemetry-instrumentation-requests==0.51b0
opentelemetry-instrumentation-logging==0.51b0
opentelemetry-instrumentation-httpx==0.51b0
opentelemetry-instrumentation-aiohttp-client==0.51b0

View file

@ -41,4 +41,5 @@ IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " (
:: Execute uvicorn :: Execute uvicorn
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ws auto
:: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto

597
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.5.20", "version": "0.6.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
@ -80,6 +80,8 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"html-entities": "^2.5.3",
"html2canvas-pro": "^1.5.8",
"i18next": "^23.10.0", "i18next": "^23.10.0",
"i18next-browser-languagedetector": "^7.2.0", "i18next-browser-languagedetector": "^7.2.0",
"i18next-resources-to-backend": "^1.2.0", "i18next-resources-to-backend": "^1.2.0",
@ -102,7 +104,7 @@
"prosemirror-schema-list": "^1.4.1", "prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"prosemirror-view": "^1.34.3", "prosemirror-view": "^1.34.3",
"pyodide": "^0.27.2", "pyodide": "^0.27.3",
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"sortablejs": "^1.15.2", "sortablejs": "^1.15.2",
"svelte-sonner": "^0.3.19", "svelte-sonner": "^0.3.19",

View file

@ -51,7 +51,7 @@ dependencies = [
"langchain==0.3.19", "langchain==0.3.19",
"langchain-community==0.3.18", "langchain-community==0.3.18",
"fake-useragent==1.5.1", "fake-useragent==2.1.0",
"chromadb==0.6.2", "chromadb==0.6.2",
"pymilvus==2.5.0", "pymilvus==2.5.0",
"qdrant-client~=1.12.0", "qdrant-client~=1.12.0",
@ -84,6 +84,7 @@ dependencies = [
"soundfile==0.13.1", "soundfile==0.13.1",
"azure-ai-documentintelligence==1.0.0", "azure-ai-documentintelligence==1.0.0",
"pillow==11.1.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",
"rank-bm25==0.2.2", "rank-bm25==0.2.2",

View file

@ -106,7 +106,7 @@ li p {
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
height: 0.4rem; height: 0.8rem;
width: 0.4rem; width: 0.4rem;
} }

View file

@ -1,6 +1,9 @@
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { convertOpenApiToToolPayload } from '$lib/utils';
import { getOpenAIModelsDirect } from './openai'; import { getOpenAIModelsDirect } from './openai';
import { toast } from 'svelte-sonner';
export const getModels = async ( export const getModels = async (
token: string = '', token: string = '',
connections: object | null = null, connections: object | null = null,
@ -114,6 +117,13 @@ export const getModels = async (
} }
} }
const tags = apiConfig.tags;
if (tags) {
for (const model of models) {
model.tags = tags;
}
}
localModels = localModels.concat(models); localModels = localModels.concat(models);
} }
} }
@ -249,6 +259,179 @@ export const stopTask = async (token: string, id: string) => {
return res; return res;
}; };
export const getToolServerData = async (token: string, url: string) => {
let error = null;
const res = await fetch(`${url}/openapi.json`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
return null;
});
if (error) {
throw error;
}
const data = {
openapi: res,
info: res.info,
specs: convertOpenApiToToolPayload(res)
};
console.log(data);
return data;
};
export const getToolServersData = async (i18n, servers: object[]) => {
return (
await Promise.all(
servers
.filter((server) => server?.config?.enable)
.map(async (server) => {
const data = await getToolServerData(server?.key, server?.url).catch((err) => {
toast.error(
i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
URL: server?.url
})
);
return null;
});
if (data) {
const { openapi, info, specs } = data;
return {
url: server?.url,
openapi: openapi,
info: info,
specs: specs
};
}
})
)
).filter((server) => server);
};
export const executeToolServer = async (
token: string,
url: string,
name: string,
params: Record<string, any>,
serverData: { openapi: any; info: any; specs: any }
) => {
let error = null;
try {
// Find the matching operationId in the OpenAPI spec
const matchingRoute = Object.entries(serverData.openapi.paths).find(([_, methods]) =>
Object.entries(methods as any).some(([__, operation]: any) => operation.operationId === name)
);
if (!matchingRoute) {
throw new Error(`No matching route found for operationId: ${name}`);
}
const [routePath, methods] = matchingRoute;
const methodEntry = Object.entries(methods as any).find(
([_, operation]: any) => operation.operationId === name
);
if (!methodEntry) {
throw new Error(`No matching method found for operationId: ${name}`);
}
const [httpMethod, operation]: [string, any] = methodEntry;
// Split parameters by type
const pathParams: Record<string, any> = {};
const queryParams: Record<string, any> = {};
let bodyParams: any = {};
if (operation.parameters) {
operation.parameters.forEach((param: any) => {
const paramName = param.name;
const paramIn = param.in;
if (params.hasOwnProperty(paramName)) {
if (paramIn === 'path') {
pathParams[paramName] = params[paramName];
} else if (paramIn === 'query') {
queryParams[paramName] = params[paramName];
}
}
});
}
let finalUrl = `${url}${routePath}`;
// Replace path parameters (`{param}`)
Object.entries(pathParams).forEach(([key, value]) => {
finalUrl = finalUrl.replace(new RegExp(`{${key}}`, 'g'), encodeURIComponent(value));
});
// Append query parameters to URL if any
if (Object.keys(queryParams).length > 0) {
const queryString = new URLSearchParams(
Object.entries(queryParams).map(([k, v]) => [k, String(v)])
).toString();
finalUrl += `?${queryString}`;
}
// Handle requestBody composite
if (operation.requestBody && operation.requestBody.content) {
const contentType = Object.keys(operation.requestBody.content)[0];
if (params !== undefined) {
bodyParams = params;
} else {
// Optional: Fallback or explicit error if body is expected but not provided
throw new Error(`Request body expected for operation '${name}' but none found.`);
}
}
// Prepare headers and request options
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
};
let requestOptions: RequestInit = {
method: httpMethod.toUpperCase(),
headers
};
if (['post', 'put', 'patch'].includes(httpMethod.toLowerCase()) && operation.requestBody) {
requestOptions.body = JSON.stringify(bodyParams);
}
const res = await fetch(finalUrl, requestOptions);
if (!res.ok) {
const resText = await res.text();
throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`);
}
return await res.json();
} catch (err: any) {
error = err.message;
console.error('API Request Error:', error);
return { error };
}
};
export const getTaskConfig = async (token: string = '') => { export const getTaskConfig = async (token: string = '') => {
let error = null; let error = null;

View file

@ -14,6 +14,7 @@
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 Tags from './common/Tags.svelte';
export let onSubmit: Function = () => {}; export let onSubmit: Function = () => {};
export let onDelete: Function = () => {}; export let onDelete: Function = () => {};
@ -31,6 +32,7 @@
let prefixId = ''; let prefixId = '';
let enable = true; let enable = true;
let tags = [];
let modelId = ''; let modelId = '';
let modelIds = []; let modelIds = [];
@ -77,17 +79,21 @@
const submitHandler = async () => { const submitHandler = async () => {
loading = true; loading = true;
if (!ollama && (!url || !key)) { if (!ollama && !url) {
loading = false; loading = false;
toast.error('URL and Key are required'); toast.error('URL is required');
return; return;
} }
// remove trailing slash from url
url = url.replace(/\/$/, '');
const connection = { const connection = {
url, url,
key, key,
config: { config: {
enable: enable, enable: enable,
tags: tags,
prefix_id: prefixId, prefix_id: prefixId,
model_ids: modelIds model_ids: modelIds
} }
@ -101,6 +107,7 @@
url = ''; url = '';
key = ''; key = '';
prefixId = ''; prefixId = '';
tags = [];
modelIds = []; modelIds = [];
}; };
@ -110,6 +117,7 @@
key = connection.key; key = connection.key;
enable = connection.config?.enable ?? true; enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? ''; prefixId = connection.config?.prefix_id ?? '';
modelIds = connection.config?.model_ids ?? []; modelIds = connection.config?.model_ids ?? [];
} }
@ -179,7 +187,7 @@
</div> </div>
</div> </div>
<Tooltip content="Verify Connection" className="self-end -mb-1"> <Tooltip content={$i18n.t('Verify Connection')} className="self-end -mb-1">
<button <button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition" class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => { on:click={() => {
@ -218,7 +226,7 @@
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden" className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={key} bind:value={key}
placeholder={$i18n.t('API Key')} placeholder={$i18n.t('API Key')}
required={!ollama} required={false}
/> />
</div> </div>
</div> </div>
@ -244,6 +252,29 @@
</div> </div>
</div> </div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class=" mb-1.5 text-xs text-gray-500">{$i18n.t('Tags')}</div>
<div class="flex-1">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" /> <hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
@ -274,12 +305,12 @@
{:else} {:else}
<div class="text-gray-500 text-xs text-center py-2 px-10"> <div class="text-gray-500 text-xs text-center py-2 px-10">
{#if ollama} {#if ollama}
{$i18n.t('Leave empty to include all models from "{{URL}}/api/tags" endpoint', { {$i18n.t('Leave empty to include all models from "{{url}}/api/tags" endpoint', {
URL: url url: url
})} })}
{:else} {:else}
{$i18n.t('Leave empty to include all models from "{{URL}}/models" endpoint', { {$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
URL: url url: url
})} })}
{/if} {/if}
</div> </div>

View file

@ -0,0 +1,215 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { models } from '$lib/stores';
import { verifyOpenAIConnection } from '$lib/apis/openai';
import { verifyOllamaConnection } from '$lib/apis/ollama';
import Modal from '$lib/components/common/Modal.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tags from './common/Tags.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
export let show = false;
export let edit = false;
export let connection = null;
let url = '';
let key = '';
let enable = true;
let loading = false;
const submitHandler = async () => {
loading = true;
// remove trailing slash from url
url = url.replace(/\/$/, '');
const connection = {
url,
key,
config: {
enable: enable
}
};
await onSubmit(connection);
loading = false;
show = false;
url = '';
key = '';
enable = true;
};
const init = () => {
if (connection) {
url = connection.url;
key = connection.key;
enable = connection.config?.enable ?? true;
}
};
$: if (show) {
init();
}
onMount(() => {
init();
});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center font-primary">
{#if edit}
{$i18n.t('Edit Connection')}
{:else}
{$i18n.t('Add Connection')}
{/if}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="px-1">
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('URL')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
autocomplete="off"
required
/>
</div>
</div>
<div class="flex flex-col shrink-0 self-end">
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch bind:state={enable} />
</Tooltip>
</div>
</div>
<div class="text-xs text-gray-500 mt-1">
{$i18n.t(`WebUI will make requests to "{{url}}/openapi.json"`, {
url: url
})}
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Key')}</div>
<div class="flex-1">
<SensitiveInput
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
</div>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
{#if edit}
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
onDelete();
show = false;
}}
>
{$i18n.t('Delete')}
</button>
{/if}
<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 flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>

View file

@ -71,7 +71,7 @@
</button> </button>
<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-left transition {selectedTab ===
'connections' 'connections'
? '' ? ''
: ' 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'}"
@ -95,7 +95,7 @@
</button> </button>
<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-left transition {selectedTab ===
'models' 'models'
? '' ? ''
: ' 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'}"
@ -121,7 +121,7 @@
</button> </button>
<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-left transition {selectedTab ===
'evaluations' 'evaluations'
? '' ? ''
: ' 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'}"
@ -136,7 +136,7 @@
</button> </button>
<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-left transition {selectedTab ===
'documents' 'documents'
? '' ? ''
: ' 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'}"
@ -166,7 +166,7 @@
</button> </button>
<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-left transition {selectedTab ===
'web' 'web'
? '' ? ''
: ' 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'}"
@ -190,7 +190,7 @@
</button> </button>
<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-left transition {selectedTab ===
'code-execution' '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'}"
@ -216,7 +216,7 @@
</button> </button>
<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-left transition {selectedTab ===
'interface' 'interface'
? '' ? ''
: ' 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'}"
@ -242,7 +242,7 @@
</button> </button>
<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-left transition {selectedTab ===
'audio' 'audio'
? '' ? ''
: ' 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'}"
@ -269,7 +269,7 @@
</button> </button>
<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-left transition {selectedTab ===
'images' 'images'
? '' ? ''
: ' 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'}"
@ -295,7 +295,7 @@
</button> </button>
<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-left transition {selectedTab ===
'pipelines' 'pipelines'
? '' ? ''
: ' 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'}"
@ -325,7 +325,7 @@
</button> </button>
<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-left transition {selectedTab ===
'db' 'db'
? '' ? ''
: ' 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'}"

View file

@ -5,6 +5,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte'; import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte'; import Wrench from '$lib/components/icons/Wrench.svelte';
@ -20,6 +21,7 @@
let showManageModal = false; let showManageModal = false;
let showConfigModal = false; let showConfigModal = false;
let showDeleteConfirmDialog = false;
</script> </script>
<AddConnectionModal <AddConnectionModal
@ -31,7 +33,9 @@
key: config?.key ?? '', key: config?.key ?? '',
config: config config: config
}} }}
{onDelete} onDelete={() => {
showDeleteConfirmDialog = true;
}}
onSubmit={(connection) => { onSubmit={(connection) => {
url = connection.url; url = connection.url;
config = { ...connection.config, key: connection.key }; config = { ...connection.config, key: connection.key };
@ -39,6 +43,14 @@
}} }}
/> />
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
onDelete();
showConfigModal = false;
}}
/>
<ManageOllamaModal bind:show={showManageModal} urlIdx={idx} /> <ManageOllamaModal bind:show={showManageModal} urlIdx={idx} />
<div class="flex gap-1.5"> <div class="flex gap-1.5">

View file

@ -6,6 +6,7 @@
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte'; import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { connect } from 'socket.io-client'; import { connect } from 'socket.io-client';
@ -19,8 +20,16 @@
export let config = {}; export let config = {};
let showConfigModal = false; let showConfigModal = false;
let showDeleteConfirmDialog = false;
</script> </script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
onDelete();
}}
/>
<AddConnectionModal <AddConnectionModal
edit edit
bind:show={showConfigModal} bind:show={showConfigModal}
@ -29,7 +38,9 @@
key, key,
config config
}} }}
{onDelete} onDelete={() => {
showDeleteConfirmDialog = true;
}}
onSubmit={(connection) => { onSubmit={(connection) => {
url = connection.url; url = connection.url;
key = connection.key; key = connection.key;

View file

@ -49,6 +49,8 @@
let contentExtractionEngine = 'default'; let contentExtractionEngine = 'default';
let tikaServerUrl = ''; let tikaServerUrl = '';
let showTikaServerUrl = false; let showTikaServerUrl = false;
let doclingServerUrl = '';
let showDoclingServerUrl = false;
let documentIntelligenceEndpoint = ''; let documentIntelligenceEndpoint = '';
let documentIntelligenceKey = ''; let documentIntelligenceKey = '';
let showDocumentIntelligenceConfig = false; let showDocumentIntelligenceConfig = false;
@ -74,6 +76,7 @@
template: '', template: '',
r: 0.0, r: 0.0,
k: 4, k: 4,
k_reranker: 4,
hybrid: false hybrid: false
}; };
@ -175,6 +178,10 @@
toast.error($i18n.t('Tika Server URL required.')); toast.error($i18n.t('Tika Server URL required.'));
return; return;
} }
if (contentExtractionEngine === 'docling' && doclingServerUrl === '') {
toast.error($i18n.t('Docling Server URL required.'));
return;
}
if ( if (
contentExtractionEngine === 'document_intelligence' && contentExtractionEngine === 'document_intelligence' &&
(documentIntelligenceEndpoint === '' || documentIntelligenceKey === '') (documentIntelligenceEndpoint === '' || documentIntelligenceKey === '')
@ -209,6 +216,7 @@
content_extraction: { content_extraction: {
engine: contentExtractionEngine, engine: contentExtractionEngine,
tika_server_url: tikaServerUrl, tika_server_url: tikaServerUrl,
docling_server_url: doclingServerUrl,
document_intelligence_config: { document_intelligence_config: {
key: documentIntelligenceKey, key: documentIntelligenceKey,
endpoint: documentIntelligenceEndpoint endpoint: documentIntelligenceEndpoint
@ -269,7 +277,10 @@
contentExtractionEngine = res.content_extraction.engine; contentExtractionEngine = res.content_extraction.engine;
tikaServerUrl = res.content_extraction.tika_server_url; tikaServerUrl = res.content_extraction.tika_server_url;
doclingServerUrl = res.content_extraction.docling_server_url;
showTikaServerUrl = contentExtractionEngine === 'tika'; showTikaServerUrl = contentExtractionEngine === 'tika';
showDoclingServerUrl = contentExtractionEngine === 'docling';
documentIntelligenceEndpoint = res.content_extraction.document_intelligence_config.endpoint; documentIntelligenceEndpoint = res.content_extraction.document_intelligence_config.endpoint;
documentIntelligenceKey = res.content_extraction.document_intelligence_config.key; documentIntelligenceKey = res.content_extraction.document_intelligence_config.key;
showDocumentIntelligenceConfig = contentExtractionEngine === 'document_intelligence'; showDocumentIntelligenceConfig = contentExtractionEngine === 'document_intelligence';
@ -337,6 +348,7 @@
> >
<option value="">{$i18n.t('Default')} </option> <option value="">{$i18n.t('Default')} </option>
<option value="tika">{$i18n.t('Tika')}</option> <option value="tika">{$i18n.t('Tika')}</option>
<option value="docling">{$i18n.t('Docling')}</option>
<option value="document_intelligence">{$i18n.t('Document Intelligence')}</option> <option value="document_intelligence">{$i18n.t('Document Intelligence')}</option>
</select> </select>
</div> </div>
@ -351,6 +363,14 @@
/> />
</div> </div>
</div> </div>
{:else if contentExtractionEngine === 'docling'}
<div class="flex w-full mt-1">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling Server URL')}
bind:value={doclingServerUrl}
/>
</div>
{:else if contentExtractionEngine === 'document_intelligence'} {:else if contentExtractionEngine === 'document_intelligence'}
<div class="my-0.5 flex gap-2 pr-2"> <div class="my-0.5 flex gap-2 pr-2">
<input <input
@ -387,8 +407,12 @@
<div class="flex items-center relative"> <div class="flex items-center relative">
<Tooltip <Tooltip
content={BYPASS_EMBEDDING_AND_RETRIEVAL content={BYPASS_EMBEDDING_AND_RETRIEVAL
? 'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.' ? $i18n.t(
: 'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'} 'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.'
)
: $i18n.t(
'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'
)}
> >
<Switch bind:state={BYPASS_EMBEDDING_AND_RETRIEVAL} /> <Switch bind:state={BYPASS_EMBEDDING_AND_RETRIEVAL} />
</Tooltip> </Tooltip>
@ -619,104 +643,6 @@
</div> </div>
</div> </div>
{/if} {/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}
<div class=" mb-2.5 flex flex-col w-full">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Reranking Model')}</div>
<div class="">
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Set reranking model (e.g. {{model}})', {
model: 'BAAI/bge-reranker-v2-m3'
})}
bind:value={rerankingModel}
/>
</div>
<button
class="px-2.5 bg-transparent text-gray-800 dark:bg-transparent dark:text-gray-100 rounded-lg transition"
on:click={() => {
rerankingModelUpdateHandler();
}}
disabled={updateRerankingModelLoading}
>
{#if updateRerankingModelLoading}
<div class="self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/>
</svg>
</div>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
{/if}
</button>
</div>
</div>
</div>
{/if}
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -725,42 +651,164 @@
<hr class=" border-gray-100 dark:border-gray-850 my-2" /> <hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-2.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('Top K')}</div> <div class=" self-center text-xs font-medium">{$i18n.t('Full Context Mode')}</div>
<div class="flex items-center relative"> <div class="flex items-center relative">
<input <Tooltip
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden" content={RAG_FULL_CONTEXT
type="number" ? $i18n.t(
placeholder={$i18n.t('Enter Top K')} 'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.'
bind:value={querySettings.k} )
autocomplete="off" : $i18n.t(
min="0" '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> </div>
{#if querySettings.hybrid === true} {#if !RAG_FULL_CONTEXT}
<div class=" mb-2.5 flex flex-col w-full justify-between"> <div class=" mb-2.5 flex w-full justify-between">
<div class=" flex w-full justify-between"> <div class=" self-center text-xs font-medium">{$i18n.t('Hybrid Search')}</div>
<div class=" self-center text-xs font-medium">{$i18n.t('Minimum Score')}</div> <div class="flex items-center relative">
<Switch
bind:state={querySettings.hybrid}
on:change={() => {
toggleHybridSearch();
}}
/>
</div>
</div>
{#if querySettings.hybrid === true}
<div class=" mb-2.5 flex flex-col w-full">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Reranking Model')}</div>
<div class="">
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Set reranking model (e.g. {{model}})', {
model: 'BAAI/bge-reranker-v2-m3'
})}
bind:value={rerankingModel}
/>
</div>
<button
class="px-2.5 bg-transparent text-gray-800 dark:bg-transparent dark:text-gray-100 rounded-lg transition"
on:click={() => {
rerankingModelUpdateHandler();
}}
disabled={updateRerankingModelLoading}
>
{#if updateRerankingModelLoading}
<div class="self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/>
</svg>
</div>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
{/if}
</button>
</div>
</div>
</div>
{/if}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Top K')}</div>
<div class="flex items-center relative">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="number"
placeholder={$i18n.t('Enter Top K')}
bind:value={querySettings.k}
autocomplete="off"
min="0"
/>
</div>
</div>
{#if querySettings.hybrid === true}
<div class="mb-2.5 flex w-full justify-between">
<div class="self-center text-xs font-medium">{$i18n.t('Top K Reranker')}</div>
<div class="flex items-center relative"> <div class="flex items-center relative">
<input <input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden" class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="number" type="number"
step="0.01" placeholder={$i18n.t('Enter Top K Reranker')}
placeholder={$i18n.t('Enter Score')} bind:value={querySettings.k_reranker}
bind:value={querySettings.r}
autocomplete="off" autocomplete="off"
min="0.0" min="0"
title={$i18n.t('The score should be a value between 0.0 (0%) and 1.0 (100%).')}
/> />
</div> </div>
</div> </div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500"> {/if}
{$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.' {#if querySettings.hybrid === true}
)} <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('Minimum Score')}</div>
<div class="flex items-center relative">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="number"
step="0.01"
placeholder={$i18n.t('Enter Score')}
bind:value={querySettings.r}
autocomplete="off"
min="0.0"
title={$i18n.t(
'The score should be a value between 0.0 (0%) and 1.0 (100%).'
)}
/>
</div>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$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.'
)}
</div>
</div> </div>
</div> {/if}
{/if} {/if}
<div class=" mb-2.5 flex flex-col w-full justify-between"> <div class=" mb-2.5 flex flex-col w-full justify-between">

View file

@ -10,6 +10,7 @@
import PencilSolid from '$lib/components/icons/PencilSolid.svelte'; import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte'; import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
export let show = false; export let show = false;
export let edit = false; export let edit = false;
@ -44,6 +45,7 @@
let imageInputElement; let imageInputElement;
let loading = false; let loading = false;
let showDeleteConfirmDialog = false;
const addModelHandler = () => { const addModelHandler = () => {
if (selectedModelId) { if (selectedModelId) {
@ -115,6 +117,14 @@
}); });
</script> </script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
dispatch('delete', model);
show = false;
}}
/>
<Modal size="sm" bind:show> <Modal size="sm" bind:show>
<div> <div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2"> <div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
@ -378,8 +388,7 @@
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-950 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center" class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-950 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button" type="button"
on:click={() => { on:click={() => {
dispatch('delete', model); showDeleteConfirmDialog = true;
show = false;
}} }}
> >
{$i18n.t('Delete')} {$i18n.t('Delete')}

View file

@ -554,7 +554,6 @@
</div> </div>
<input <input
class="w-full bg-transparent outline-hidden py-0.5" class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter certificate path')} placeholder={$i18n.t('Enter certificate path')}
bind:value={LDAP_SERVER.certificate_path} bind:value={LDAP_SERVER.certificate_path}
/> />
@ -610,6 +609,14 @@
<Switch bind:state={adminConfig.ENABLE_CHANNELS} /> <Switch bind:state={adminConfig.ENABLE_CHANNELS} />
</div> </div>
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('User Webhooks')}
</div>
<Switch bind:state={adminConfig.ENABLE_USER_WEBHOOKS} />
</div>
<div class="mb-2.5 w-full justify-between"> <div class="mb-2.5 w-full justify-between">
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('WebUI URL')}</div> <div class=" self-center text-xs font-medium">{$i18n.t('WebUI URL')}</div>

View file

@ -191,11 +191,15 @@
} }
if (config.comfyui.COMFYUI_WORKFLOW) { if (config.comfyui.COMFYUI_WORKFLOW) {
config.comfyui.COMFYUI_WORKFLOW = JSON.stringify( try {
JSON.parse(config.comfyui.COMFYUI_WORKFLOW), config.comfyui.COMFYUI_WORKFLOW = JSON.stringify(
null, JSON.parse(config.comfyui.COMFYUI_WORKFLOW),
2 null,
); 2
);
} catch (e) {
console.log(e);
}
} }
requiredWorkflowNodes = requiredWorkflowNodes.map((node) => { requiredWorkflowNodes = requiredWorkflowNodes.map((node) => {

View file

@ -29,6 +29,12 @@
import Wrench from '$lib/components/icons/Wrench.svelte'; import Wrench from '$lib/components/icons/Wrench.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte'; import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import ManageModelsModal from './Models/ManageModelsModal.svelte'; import ManageModelsModal from './Models/ManageModelsModal.svelte';
import ModelMenu from '$lib/components/admin/Settings/Models/ModelMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
import Eye from '$lib/components/icons/Eye.svelte';
let shiftKey = false;
let importFiles; let importFiles;
let modelsImportInputElement: HTMLInputElement; let modelsImportInputElement: HTMLInputElement;
@ -146,8 +152,62 @@
); );
}; };
const hideModelHandler = async (model) => {
model.meta = {
...model.meta,
hidden: !(model?.meta?.hidden ?? false)
};
console.log(model);
toast.success(
model.meta.hidden
? $i18n.t(`Model {{name}} is now hidden`, {
name: model.id
})
: $i18n.t(`Model {{name}} is now visible`, {
name: model.id
})
);
upsertModelHandler(model);
};
const exportModelHandler = async (model) => {
let blob = new Blob([JSON.stringify([model])], {
type: 'application/json'
});
saveAs(blob, `${model.id}-${Date.now()}.json`);
};
onMount(async () => { onMount(async () => {
init(); await init();
const onKeyDown = (event) => {
if (event.key === 'Shift') {
shiftKey = true;
}
};
const onKeyUp = (event) => {
if (event.key === 'Shift') {
shiftKey = false;
}
};
const onBlur = () => {
shiftKey = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur-sm', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur-sm', onBlur);
};
}); });
</script> </script>
@ -211,7 +271,10 @@
{#if models.length > 0} {#if models.length > 0}
{#each filteredModels as model, modelIdx (model.id)} {#each filteredModels as model, modelIdx (model.id)}
<div <div
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-lg transition" class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-lg transition {model
?.meta?.hidden
? 'opacity-50 dark:opacity-50'
: ''}"
id="model-item-{model.id}" id="model-item-{model.id}"
> >
<button <button
@ -261,41 +324,78 @@
</div> </div>
</button> </button>
<div class="flex flex-row gap-0.5 items-center self-center"> <div class="flex flex-row gap-0.5 items-center self-center">
<button {#if shiftKey}
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" <Tooltip content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}>
type="button" <button
on:click={() => { class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
selectedModelId = model.id; type="button"
}} on:click={() => {
> hideModelHandler(model);
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button>
<div class="ml-1">
<Tooltip
content={(model?.is_active ?? true) ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelHandler(model);
}} }}
/> >
{#if model?.meta?.hidden}
<EyeSlash />
{:else}
<Eye />
{/if}
</button>
</Tooltip> </Tooltip>
</div> {:else}
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedModelId = model.id;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button>
<ModelMenu
user={$user}
{model}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
onClose={() => {}}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
>
<EllipsisHorizontal className="size-5" />
</button>
</ModelMenu>
<div class="ml-1">
<Tooltip
content={(model?.is_active ?? true) ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelHandler(model);
}}
/>
</Tooltip>
</div>
{/if}
</div> </div>
</div> </div>
{/each} {/each}

View file

@ -33,6 +33,7 @@
if (modelListElement) { if (modelListElement) {
sortable = Sortable.create(modelListElement, { sortable = Sortable.create(modelListElement, {
animation: 150, animation: 150,
handle: '.item-handle',
onUpdate: async (event) => { onUpdate: async (event) => {
positionChangeHandler(); positionChangeHandler();
} }
@ -47,7 +48,7 @@
<div class=" flex gap-2 w-full justify-between items-center" id="model-item-{modelId}"> <div class=" flex gap-2 w-full justify-between items-center" id="model-item-{modelId}">
<Tooltip content={modelId} placement="top-start"> <Tooltip content={modelId} placement="top-start">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<EllipsisVertical className="size-4 cursor-move" /> <EllipsisVertical className="size-4 cursor-move item-handle" />
<div class=" text-sm flex-1 py-1 rounded-lg"> <div class=" text-sm flex-1 py-1 rounded-lg">
{#if $models.find((model) => model.id === modelId)} {#if $models.find((model) => model.id === modelId)}

View file

@ -0,0 +1,116 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Tags from '$lib/components/chat/Tags.svelte';
import Share from '$lib/components/icons/Share.svelte';
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import ArrowUpCircle from '$lib/components/icons/ArrowUpCircle.svelte';
import { config } from '$lib/stores';
const i18n = getContext('i18n');
export let user;
export let model;
export let exportHandler: Function;
export let hideHandler: Function;
export let onClose: Function;
let show = false;
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[160px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<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-md"
on:click={() => {
hideHandler();
}}
>
{#if model?.meta?.hidden ?? false}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
{/if}
<div class="flex items-center">
{#if model?.meta?.hidden ?? false}
{$i18n.t('Show Model')}
{:else}
{$i18n.t('Hide Model')}
{/if}
</div>
</DropdownMenu.Item>
<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-md"
on:click={() => {
exportHandler();
}}
>
<ArrowDownTray />
<div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>

View file

@ -462,8 +462,12 @@
<div class="flex items-center relative"> <div class="flex items-center relative">
<Tooltip <Tooltip
content={webConfig.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL content={webConfig.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
? 'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.' ? $i18n.t(
: 'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'} 'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.'
)
: $i18n.t(
'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} /> <Switch bind:state={webConfig.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL} />
</Tooltip> </Tooltip>

View file

@ -52,12 +52,19 @@
prompts: false, prompts: false,
tools: false tools: false
}, },
sharing: {
public_models: false,
public_knowledge: false,
public_prompts: false,
public_tools: false
},
chat: { chat: {
controls: true, controls: true,
file_upload: true, file_upload: true,
delete: true, delete: true,
edit: true, edit: true,
temporary: true temporary: true,
temporary_enforced: true
}, },
features: { features: {
web_search: true, web_search: true,

View file

@ -9,6 +9,7 @@
import Users from './Users.svelte'; import Users from './Users.svelte';
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte'; import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
export let onSubmit: Function = () => {}; export let onSubmit: Function = () => {};
export let onDelete: Function = () => {}; export let onDelete: Function = () => {};
@ -25,6 +26,7 @@
let selectedTab = 'general'; let selectedTab = 'general';
let loading = false; let loading = false;
let showDeleteConfirmDialog = false;
export let name = ''; export let name = '';
export let description = ''; export let description = '';
@ -88,6 +90,14 @@
}); });
</script> </script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
onDelete();
show = false;
}}
/>
<Modal size="md" bind:show> <Modal size="md" bind:show>
<div> <div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5"> <div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
@ -263,18 +273,19 @@
{/if} {/if}
</div> --> </div> -->
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5"> <div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
{#if edit} {#if edit}
<button <button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center" class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button" type="button"
on:click={() => { on:click={() => {
onDelete(); showDeleteConfirmDialog = true;
show = false;
}} }}
> >
{$i18n.t('Delete')} {$i18n.t('Delete')}
</button> </button>
{:else}
<div></div>
{/if} {/if}
<button <button

View file

@ -13,12 +13,19 @@
prompts: false, prompts: false,
tools: false tools: false
}, },
sharing: {
public_models: false,
public_knowledge: false,
public_prompts: false,
public_tools: false
},
chat: { chat: {
controls: true, controls: true,
delete: true, delete: true,
edit: true, edit: true,
file_upload: true,
temporary: true, temporary: true,
file_upload: true temporary_enforced: true
}, },
features: { features: {
web_search: true, web_search: true,
@ -39,6 +46,7 @@
...defaults, ...defaults,
...obj, ...obj,
workspace: { ...defaults.workspace, ...obj.workspace }, workspace: { ...defaults.workspace, ...obj.workspace },
sharing: { ...defaults.sharing, ...obj.sharing },
chat: { ...defaults.chat, ...obj.chat }, chat: { ...defaults.chat, ...obj.chat },
features: { ...defaults.features, ...obj.features } features: { ...defaults.features, ...obj.features }
}; };
@ -194,6 +202,40 @@
<hr class=" border-gray-100 dark:border-gray-850 my-2" /> <hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Sharing Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Models Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_models} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_knowledge} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_prompts} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Tools Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_tools} />
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div> <div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div> <div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
@ -236,6 +278,16 @@
<Switch bind:state={permissions.chat.temporary} /> <Switch bind:state={permissions.chat.temporary} />
</div> </div>
{#if permissions.chat.temporary}
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enforce Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary_enforced} />
</div>
{/if}
</div> </div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" /> <hr class=" border-gray-100 dark:border-gray-850 my-2" />

View file

@ -12,6 +12,7 @@
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -19,6 +20,8 @@
export let user; export let user;
let chats = null; let chats = null;
let showDeleteConfirmDialog = false;
let chatToDelete = null;
const deleteChatHandler = async (chatId) => { const deleteChatHandler = async (chatId) => {
const res = await deleteChatById(localStorage.token, chatId).catch((error) => { const res = await deleteChatById(localStorage.token, chatId).catch((error) => {
@ -50,6 +53,16 @@
} }
</script> </script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
if (chatToDelete) {
deleteChatHandler(chatToDelete);
chatToDelete = null;
}
}}
/>
<Modal size="lg" bind:show> <Modal size="lg" bind:show>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4"> <div class=" flex justify-between dark:text-gray-300 px-5 pt-4">
<div class=" text-lg font-medium self-center capitalize"> <div class=" text-lg font-medium self-center capitalize">
@ -142,7 +155,8 @@
<button <button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => { on:click={async () => {
deleteChatHandler(chat.id); chatToDelete = chat.id;
showDeleteConfirmDialog = true;
}} }}
> >
<svg <svg

View file

@ -73,10 +73,13 @@
<div class="text-2xl font-medium capitalize">{channel.name}</div> <div class="text-2xl font-medium capitalize">{channel.name}</div>
<div class=" text-gray-500"> <div class=" text-gray-500">
This channel was created on {dayjs(channel.created_at / 1000000).format( {$i18n.t(
'MMMM D, YYYY' 'This channel was created on {{createdAt}}. This is the very beginning of the {{channelName}} channel.',
)}. This is the very beginning of the {channel.name} {
channel. createdAt: dayjs(channel.created_at / 1000000).format('MMMM D, YYYY'),
channelName: channel.name
}
)}
</div> </div>
</div> </div>
{:else} {:else}

View file

@ -35,7 +35,8 @@
showOverview, showOverview,
chatTitle, chatTitle,
showArtifacts, showArtifacts,
tools tools,
toolServers
} from '$lib/stores'; } from '$lib/stores';
import { import {
convertMessagesToHistory, convertMessagesToHistory,
@ -119,6 +120,7 @@
let imageGenerationEnabled = false; let imageGenerationEnabled = false;
let webSearchEnabled = false; let webSearchEnabled = false;
let codeInterpreterEnabled = false; let codeInterpreterEnabled = false;
let chat = null; let chat = null;
let tags = []; let tags = [];
@ -212,7 +214,14 @@
const _chatId = JSON.parse(JSON.stringify($chatId)); const _chatId = JSON.parse(JSON.stringify($chatId));
let _messageId = JSON.parse(JSON.stringify(message.id)); let _messageId = JSON.parse(JSON.stringify(message.id));
let messageChildrenIds = history.messages[_messageId].childrenIds; let messageChildrenIds = [];
if (_messageId === null) {
messageChildrenIds = Object.keys(history.messages).filter(
(id) => history.messages[id].parentId === null
);
} else {
messageChildrenIds = history.messages[_messageId].childrenIds;
}
while (messageChildrenIds.length !== 0) { while (messageChildrenIds.length !== 0) {
_messageId = messageChildrenIds.at(-1); _messageId = messageChildrenIds.at(-1);
@ -286,18 +295,10 @@
} else if (type === 'chat:tags') { } else if (type === 'chat:tags') {
chat = await getChatById(localStorage.token, $chatId); chat = await getChatById(localStorage.token, $chatId);
allTags.set(await getAllTags(localStorage.token)); allTags.set(await getAllTags(localStorage.token));
} else if (type === 'message') { } else if (type === 'chat:message:delta' || type === 'message') {
message.content += data.content; message.content += data.content;
} else if (type === 'replace') { } else if (type === 'chat:message' || type === 'replace') {
message.content = data.content; message.content = data.content;
} else if (type === 'action') {
if (data.action === 'continue') {
const continueButton = document.getElementById('continue-response-button');
if (continueButton) {
continueButton.click();
}
}
} else if (type === 'confirmation') { } else if (type === 'confirmation') {
eventCallback = cb; eventCallback = cb;
@ -384,7 +385,7 @@
if (event.data.type === 'input:prompt:submit') { if (event.data.type === 'input:prompt:submit') {
console.debug(event.data.text); console.debug(event.data.text);
if (prompt !== '') { if (event.data.text !== '') {
await tick(); await tick();
submitPrompt(event.data.text); submitPrompt(event.data.text);
} }
@ -887,6 +888,8 @@
await chats.set(await getChatList(localStorage.token, $currentChatPage)); await chats.set(await getChatList(localStorage.token, $currentChatPage));
} }
} }
taskId = null;
}; };
const chatActionHandler = async (chatId, actionId, modelId, responseMessageId, event = null) => { const chatActionHandler = async (chatId, actionId, modelId, responseMessageId, event = null) => {
@ -1276,12 +1279,13 @@
prompt = ''; prompt = '';
// Reset chat input textarea // Reset chat input textarea
const chatInputElement = document.getElementById('chat-input'); if (!($settings?.richTextInput ?? true)) {
const chatInputElement = document.getElementById('chat-input');
if (chatInputElement) { if (chatInputElement) {
await tick(); 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));
@ -1563,6 +1567,7 @@
files: (files?.length ?? 0) > 0 ? files : undefined, files: (files?.length ?? 0) > 0 ? files : undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined, tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
tool_servers: $toolServers,
features: { features: {
image_generation: image_generation:
@ -1621,7 +1626,7 @@
: {}) : {})
}, },
`${WEBUI_BASE_URL}/api` `${WEBUI_BASE_URL}/api`
).catch((error) => { ).catch(async (error) => {
toast.error(`${error}`); toast.error(`${error}`);
responseMessage.error = { responseMessage.error = {
@ -1634,10 +1639,12 @@
return null; return null;
}); });
console.log(res);
if (res) { if (res) {
taskId = res.task_id; if (res.error) {
await handleOpenAIError(res.error, responseMessage);
} else {
taskId = res.task_id;
}
} }
await tick(); await tick();
@ -1654,9 +1661,11 @@
console.error(innerError); console.error(innerError);
if ('detail' in innerError) { if ('detail' in innerError) {
// FastAPI error
toast.error(innerError.detail); toast.error(innerError.detail);
errorMessage = innerError.detail; errorMessage = innerError.detail;
} else if ('error' in innerError) { } else if ('error' in innerError) {
// OpenAI error
if ('message' in innerError.error) { if ('message' in innerError.error) {
toast.error(innerError.error.message); toast.error(innerError.error.message);
errorMessage = innerError.error.message; errorMessage = innerError.error.message;
@ -1665,6 +1674,7 @@
errorMessage = innerError.error; errorMessage = innerError.error;
} }
} else if ('message' in innerError) { } else if ('message' in innerError) {
// OpenAI error
toast.error(innerError.message); toast.error(innerError.message);
errorMessage = innerError.message; errorMessage = innerError.message;
} }
@ -1683,9 +1693,10 @@
history.messages[responseMessage.id] = responseMessage; history.messages[responseMessage.id] = responseMessage;
}; };
const stopResponse = () => { const stopResponse = async () => {
if (taskId) { if (taskId) {
const res = stopTask(localStorage.token, taskId).catch((error) => { const res = await stopTask(localStorage.token, taskId).catch((error) => {
toast.error(`${error}`);
return null; return null;
}); });
@ -2031,6 +2042,7 @@
bind:codeInterpreterEnabled bind:codeInterpreterEnabled
bind:webSearchEnabled bind:webSearchEnabled
bind:atSelectedModel bind:atSelectedModel
toolServers={$toolServers}
transparentBackground={$settings?.backgroundImageUrl ?? false} transparentBackground={$settings?.backgroundImageUrl ?? false}
{stopResponse} {stopResponse}
{createMessagePair} {createMessagePair}
@ -2084,6 +2096,7 @@
bind:webSearchEnabled bind:webSearchEnabled
bind:atSelectedModel bind:atSelectedModel
transparentBackground={$settings?.backgroundImageUrl ?? false} transparentBackground={$settings?.backgroundImageUrl ?? false}
toolServers={$toolServers}
{stopResponse} {stopResponse}
{createMessagePair} {createMessagePair}
on:upload={async (e) => { on:upload={async (e) => {

View file

@ -263,7 +263,7 @@
</div> </div>
{:else} {:else}
<div <div
class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border dark:border-gray-850 w-72 rounded-full shadow-xl" class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border border-gray-100 dark:border-gray-850 w-72 rounded-full shadow-xl"
> >
<input <input
type="text" type="text"

View file

@ -30,45 +30,45 @@
</button> </button>
</div> </div>
{#if $user.role === 'admin' || $user?.permissions.chat?.controls} <div class=" dark:text-gray-200 text-sm font-primary py-0.5 px-0.5">
<div class=" dark:text-gray-200 text-sm font-primary py-0.5 px-0.5"> {#if chatFiles.length > 0}
{#if chatFiles.length > 0} <Collapsible title={$i18n.t('Files')} open={true} buttonClassName="w-full">
<Collapsible title={$i18n.t('Files')} open={true} buttonClassName="w-full"> <div class="flex flex-col gap-1 mt-1.5" slot="content">
<div class="flex flex-col gap-1 mt-1.5" slot="content"> {#each chatFiles as file, fileIdx}
{#each chatFiles as file, fileIdx} <FileItem
<FileItem className="w-full"
className="w-full" item={file}
item={file} edit={true}
edit={true} url={file?.url ? file.url : null}
url={file?.url ? file.url : null} name={file.name}
name={file.name} type={file.type}
type={file.type} size={file?.size}
size={file?.size} dismissible={true}
dismissible={true} on:dismiss={() => {
on:dismiss={() => { // Remove the file from the chatFiles array
// Remove the file from the chatFiles array
chatFiles.splice(fileIdx, 1); chatFiles.splice(fileIdx, 1);
chatFiles = chatFiles; chatFiles = chatFiles;
}} }}
on:click={() => { on:click={() => {
console.log(file); console.log(file);
}} }}
/> />
{/each} {/each}
</div>
</Collapsible>
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
{/if}
<Collapsible bind:open={showValves} title={$i18n.t('Valves')} buttonClassName="w-full">
<div class="text-sm" slot="content">
<Valves show={showValves} />
</div> </div>
</Collapsible> </Collapsible>
<hr class="my-2 border-gray-50 dark:border-gray-700/10" /> <hr class="my-2 border-gray-50 dark:border-gray-700/10" />
{/if}
<Collapsible bind:open={showValves} title={$i18n.t('Valves')} buttonClassName="w-full">
<div class="text-sm" slot="content">
<Valves show={showValves} />
</div>
</Collapsible>
{#if $user.role === 'admin' || $user?.permissions.chat?.controls}
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
<Collapsible title={$i18n.t('System Prompt')} open={true} buttonClassName="w-full"> <Collapsible title={$i18n.t('System Prompt')} open={true} buttonClassName="w-full">
<div class="" slot="content"> <div class="" slot="content">
@ -90,10 +90,6 @@
</div> </div>
</div> </div>
</Collapsible> </Collapsible>
</div> {/if}
{:else} </div>
<div class="text-sm dark:text-gray-300 text-center py-2 px-10">
{$i18n.t('You do not have permission to access this feature.')}
</div>
{/if}
</div> </div>

View file

@ -46,6 +46,7 @@
import Photo from '../icons/Photo.svelte'; import Photo from '../icons/Photo.svelte';
import CommandLine from '../icons/CommandLine.svelte'; import CommandLine from '../icons/CommandLine.svelte';
import { KokoroWorker } from '$lib/workers/KokoroWorker'; import { KokoroWorker } from '$lib/workers/KokoroWorker';
import ToolServersModal from './ToolServersModal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -68,6 +69,8 @@
export let prompt = ''; export let prompt = '';
export let files = []; export let files = [];
export let toolServers = [];
export let selectedToolIds = []; export let selectedToolIds = [];
export let imageGenerationEnabled = false; export let imageGenerationEnabled = false;
@ -82,6 +85,8 @@
webSearchEnabled webSearchEnabled
}); });
let showToolServers = false;
let loaded = false; let loaded = false;
let recording = false; let recording = false;
@ -343,6 +348,8 @@
<FilesOverlay show={dragged} /> <FilesOverlay show={dragged} />
<ToolServersModal bind:show={showToolServers} />
{#if loaded} {#if loaded}
<div class="w-full font-primary"> <div class="w-full font-primary">
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center"> <div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
@ -417,54 +424,6 @@
</div> </div>
{/if} {/if}
{#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 gap-2.5 text-sm dark:text-gray-500">
<div class="pl-1">
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-blue-500" />
</span>
</div>
<div class=" translate-y-[0.5px]">{$i18n.t('Search the internet')}</div>
</div>
</div>
{/if}
{#if imageGenerationEnabled}
<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="pl-1">
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-teal-500" />
</span>
</div>
<div class=" translate-y-[0.5px]">{$i18n.t('Generate an image')}</div>
</div>
</div>
{/if}
{#if codeInterpreterEnabled}
<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="pl-1">
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span>
</div>
<div class=" translate-y-[0.5px]">{$i18n.t('Execute code for analysis')}</div>
</div>
</div>
{/if}
{#if atSelectedModel !== undefined} {#if atSelectedModel !== undefined}
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500"> <div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
@ -576,7 +535,7 @@
}} }}
> >
<div <div
class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-600/5 dark:bg-gray-400/5 dark:text-gray-100" class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-100 dark:border-gray-850 hover:border-gray-200 focus-within:border-gray-200 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection ?? 'LTR'} dir={$settings?.chatDirection ?? 'LTR'}
> >
{#if files.length > 0} {#if files.length > 0}
@ -687,7 +646,8 @@
))} ))}
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')} placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
largeTextAsFile={$settings?.largeTextAsFile ?? false} largeTextAsFile={$settings?.largeTextAsFile ?? false}
autocomplete={$config?.features.enable_autocomplete_generation} autocomplete={$config?.features?.enable_autocomplete_generation &&
($settings?.promptAutocomplete ?? false)}
generateAutoCompletion={async (text) => { generateAutoCompletion={async (text) => {
if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) { if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) {
toast.error($i18n.t('Please select a model first.')); toast.error($i18n.t('Please select a model first.'));
@ -895,7 +855,6 @@
on:keydown={async (e) => { on:keydown={async (e) => {
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
console.log('keydown', e);
const commandsContainerElement = const commandsContainerElement =
document.getElementById('commands-container'); document.getElementById('commands-container');
@ -997,7 +956,6 @@
return; return;
} }
console.log('keypress', e);
// Prevent Enter key from creating a new line // Prevent Enter key from creating a new line
const isCtrlPressed = e.ctrlKey || e.metaKey; const isCtrlPressed = e.ctrlKey || e.metaKey;
const enterPressed = const enterPressed =
@ -1175,14 +1133,14 @@
<button <button
on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)} on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
type="button" type="button"
class="px-1.5 @sm:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {webSearchEnabled || class="px-1.5 @xl:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {webSearchEnabled ||
($settings?.webSearch ?? false) === 'always' ($settings?.webSearch ?? false) === 'always'
? 'bg-blue-100 dark:bg-blue-500/20 text-blue-500 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-500 dark:text-blue-400'
: 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'}" : 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'}"
> >
<GlobeAlt className="size-5" strokeWidth="1.75" /> <GlobeAlt className="size-5" strokeWidth="1.75" />
<span <span
class="hidden @sm:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5" class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
>{$i18n.t('Web Search')}</span >{$i18n.t('Web Search')}</span
> >
</button> </button>
@ -1195,13 +1153,13 @@
on:click|preventDefault={() => on:click|preventDefault={() =>
(imageGenerationEnabled = !imageGenerationEnabled)} (imageGenerationEnabled = !imageGenerationEnabled)}
type="button" type="button"
class="px-1.5 @sm:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {imageGenerationEnabled class="px-1.5 @xl:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {imageGenerationEnabled
? 'bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400' ? 'bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400'
: 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 '}" : 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 '}"
> >
<Photo className="size-5" strokeWidth="1.75" /> <Photo className="size-5" strokeWidth="1.75" />
<span <span
class="hidden @sm:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5" class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
>{$i18n.t('Image')}</span >{$i18n.t('Image')}</span
> >
</button> </button>
@ -1214,13 +1172,13 @@
on:click|preventDefault={() => on:click|preventDefault={() =>
(codeInterpreterEnabled = !codeInterpreterEnabled)} (codeInterpreterEnabled = !codeInterpreterEnabled)}
type="button" type="button"
class="px-1.5 @sm:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {codeInterpreterEnabled class="px-1.5 @xl:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {codeInterpreterEnabled
? 'bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400' ? 'bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400'
: 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 '}" : 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 '}"
> >
<CommandLine className="size-5" strokeWidth="1.75" /> <CommandLine className="size-5" strokeWidth="1.75" />
<span <span
class="hidden @sm:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5" class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
>{$i18n.t('Code Interpreter')}</span >{$i18n.t('Code Interpreter')}</span
> >
</button> </button>
@ -1231,6 +1189,47 @@
</div> </div>
<div class="self-end flex space-x-1 mr-1 shrink-0"> <div class="self-end flex space-x-1 mr-1 shrink-0">
{#if toolServers.length > 0}
<Tooltip
content={$i18n.t('{{COUNT}} Available Tool Servers', {
COUNT: toolServers.length
})}
>
<button
class="translate-y-[1.5px] flex gap-1 items-center text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg px-1.5 py-0.5 mr-0.5 self-center border border-gray-100 dark:border-gray-800 transition"
aria-label="Available Tool Servers"
type="button"
on:click={() => {
showToolServers = !showToolServers;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-3"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75a4.5 4.5 0 0 1-4.884 4.484c-1.076-.091-2.264.071-2.95.904l-7.152 8.684a2.548 2.548 0 1 1-3.586-3.586l8.684-7.152c.833-.686.995-1.874.904-2.95a4.5 4.5 0 0 1 6.336-4.486l-3.276 3.276a3.004 3.004 0 0 0 2.25 2.25l3.276-3.276c.256.565.398 1.192.398 1.852Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.867 19.125h.008v.008h-.008v-.008Z"
/>
</svg>
<span class="text-xs">
{toolServers.length}
</span>
</button>
</Tooltip>
{/if}
{#if !history?.currentId || history.messages[history.currentId]?.done == true} {#if !history?.currentId || history.messages[history.currentId]?.done == true}
<Tooltip content={$i18n.t('Record voice')}> <Tooltip content={$i18n.t('Record voice')}>
<button <button

View file

@ -210,7 +210,7 @@
{/if} {/if}
<div class="line-clamp-1"> <div class="line-clamp-1">
{item?.name} {decodeURIComponent(item?.name)}
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { prompts, user } from '$lib/stores'; import { prompts, settings, user } from '$lib/stores';
import { import {
findWordIndices, findWordIndices,
getUserPosition, getUserPosition,
@ -120,7 +120,21 @@
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday); text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
} }
prompt = text; const lines = prompt.split('\n');
const lastLine = lines.pop();
const lastLineWords = lastLine.split(' ');
const lastWord = lastLineWords.pop();
if ($settings?.richTextInput ?? true) {
lastLineWords.push(`${text.replace(/</g, '&lt;').replace(/>/g, '&gt;')}`);
lines.push(lastLineWords.join(' '));
} else {
lastLineWords.push(text);
lines.push(lastLineWords.join(' '));
}
prompt = lines.join('\n');
const chatInputContainerElement = document.getElementById('chat-input-container'); const chatInputContainerElement = document.getElementById('chat-input-container');
const chatInputElement = document.getElementById('chat-input'); const chatInputElement = document.getElementById('chat-input');

View file

@ -94,8 +94,8 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[220px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm" class="w-full max-w-[200px] rounded-xl px-1 py-1 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={15} sideOffset={10}
alignOffset={-8} alignOffset={-8}
side="top" side="top"
align="start" align="start"

View file

@ -107,6 +107,47 @@
} }
}; };
const gotoMessage = async (message, idx) => {
// Determine the correct sibling list (either parent's children or root messages)
let siblings;
if (message.parentId !== null) {
siblings = history.messages[message.parentId].childrenIds;
} else {
siblings = Object.values(history.messages)
.filter((msg) => msg.parentId === null)
.map((msg) => msg.id);
}
// Clamp index to a valid range
idx = Math.max(0, Math.min(idx, siblings.length - 1));
let messageId = siblings[idx];
// If we're navigating to a different message
if (message.id !== messageId) {
// Drill down to the deepest child of that branch
let messageChildrenIds = history.messages[messageId].childrenIds;
while (messageChildrenIds.length !== 0) {
messageId = messageChildrenIds.at(-1);
messageChildrenIds = history.messages[messageId].childrenIds;
}
history.currentId = messageId;
}
await tick();
// Optional auto-scroll
if ($settings?.scrollOnBranchChange ?? true) {
const element = document.getElementById('messages-container');
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
setTimeout(() => {
scrollToBottom();
}, 100);
}
};
const showPreviousMessage = async (message) => { const showPreviousMessage = async (message) => {
if (message.parentId !== null) { if (message.parentId !== null) {
let messageId = let messageId =
@ -408,6 +449,7 @@
messageId={message.id} messageId={message.id}
idx={messageIdx} idx={messageIdx}
{user} {user}
{gotoMessage}
{showPreviousMessage} {showPreviousMessage}
{showNextMessage} {showNextMessage}
{updateChat} {updateChat}

View file

@ -102,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-${id}-${idx}`} id={`source-${id}-${idx + 1}`}
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;
@ -117,14 +117,14 @@
<div <div
class="flex-1 mx-1 truncate text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white transition" class="flex-1 mx-1 truncate text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white transition"
> >
{citation.source.name} {decodeURIComponent(citation.source.name)}
</div> </div>
</button> </button>
{/each} {/each}
</div> </div>
{:else} {:else}
<Collapsible <Collapsible
id="collapsible-sources" id={`collapsible-${id}`}
bind:open={isCollapsibleOpen} bind:open={isCollapsibleOpen}
className="w-full max-w-full " className="w-full max-w-full "
buttonClassName="w-fit max-w-full" buttonClassName="w-fit max-w-full"
@ -157,7 +157,7 @@
</div> </div>
{/if} {/if}
<div class="flex-1 mx-1 truncate"> <div class="flex-1 mx-1 truncate">
{citation.source.name} {decodeURIComponent(citation.source.name)}
</div> </div>
</button> </button>
{/each} {/each}
@ -181,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-${id}-${idx}`} id={`source-${id}-${idx + 1}`}
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;
@ -194,7 +194,7 @@
</div> </div>
{/if} {/if}
<div class="flex-1 mx-1 truncate"> <div class="flex-1 mx-1 truncate">
{citation.source.name} {decodeURIComponent(citation.source.name)}
</div> </div>
</button> </button>
{/each} {/each}

View file

@ -98,7 +98,7 @@
: `#`} : `#`}
target="_blank" target="_blank"
> >
{document?.metadata?.name ?? document.source.name} {decodeURIComponent(document?.metadata?.name ?? document.source.name)}
</a> </a>
{#if document?.metadata?.page} {#if document?.metadata?.page}
<span class="text-xs text-gray-500 dark:text-gray-400"> <span class="text-xs text-gray-500 dark:text-gray-400">
@ -128,11 +128,11 @@
{percentage.toFixed(2)}% {percentage.toFixed(2)}%
</span> </span>
<span class="text-gray-500 dark:text-gray-500"> <span class="text-gray-500 dark:text-gray-500">
({document.distance.toFixed(4)}) ({(document?.distance ?? 0).toFixed(4)})
</span> </span>
{:else} {:else}
<span class="text-gray-500 dark:text-gray-500"> <span class="text-gray-500 dark:text-gray-500">
{document.distance.toFixed(4)} {(document?.distance ?? 0).toFixed(4)}
</span> </span>
{/if} {/if}
</div> </div>

View file

@ -27,6 +27,7 @@
export let save = false; export let save = false;
export let run = true; export let run = true;
export let collapsed = false;
export let token; export let token;
export let lang = ''; export let lang = '';
@ -60,7 +61,6 @@
let result = null; let result = null;
let files = null; let files = null;
let collapsed = false;
let copied = false; let copied = false;
let saved = false; let saved = false;
@ -441,7 +441,9 @@
{#if ($config?.features?.enable_code_execution ?? true) && (lang.toLowerCase() === 'python' || lang.toLowerCase() === 'py' || (lang === '' && checkPythonCode(code)))} {#if ($config?.features?.enable_code_execution ?? true) && (lang.toLowerCase() === 'python' || lang.toLowerCase() === 'py' || (lang === '' && checkPythonCode(code)))}
{#if executing} {#if executing}
<div class="run-code-button bg-none border-none p-1 cursor-not-allowed">Running</div> <div class="run-code-button bg-none border-none p-1 cursor-not-allowed">
{$i18n.t('Running')}
</div>
{:else if run} {:else if run}
<button <button
class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5" class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"

View file

@ -84,7 +84,12 @@
} }
if (floatingButtonsElement) { if (floatingButtonsElement) {
floatingButtonsElement.closeHandler(); // check if closeHandler is defined
if (typeof floatingButtonsElement?.closeHandler === 'function') {
// call the closeHandler function
floatingButtonsElement?.closeHandler();
}
} }
}; };

View file

@ -11,7 +11,7 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let id; export let id = '';
export let content; export let content;
export let model = null; export let model = null;
export let save = false; export let save = false;

View file

@ -0,0 +1,108 @@
<script lang="ts" context="module">
import { marked, type Token } from 'marked';
type AlertType = 'NOTE' | 'TIP' | 'IMPORTANT' | 'WARNING' | 'CAUTION';
interface AlertTheme {
border: string;
text: string;
icon: ComponentType;
}
export interface AlertData {
type: AlertType;
text: string;
tokens: Token[];
}
const alertStyles: Record<AlertType, AlertTheme> = {
NOTE: {
border: 'border-sky-500',
text: 'text-sky-500',
icon: Info
},
TIP: {
border: 'border-emerald-500',
text: 'text-emerald-500',
icon: LightBlub
},
IMPORTANT: {
border: 'border-purple-500',
text: 'text-purple-500',
icon: Star
},
WARNING: {
border: 'border-yellow-500',
text: 'text-yellow-500',
icon: ArrowRightCircle
},
CAUTION: {
border: 'border-rose-500',
text: 'text-rose-500',
icon: Bolt
}
};
export function alertComponent(token: Token): AlertData | false {
const regExpStr = `^(?:\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\])\\s*?\n*`;
const regExp = new RegExp(regExpStr);
const matches = token.text?.match(regExp);
if (matches && matches.length) {
const alertType = matches[1] as AlertType;
const newText = token.text.replace(regExp, '');
const newTokens = marked.lexer(newText);
return {
type: alertType,
text: newText,
tokens: newTokens
};
}
return false;
}
</script>
<script lang="ts">
import Info from '$lib/components/icons/Info.svelte';
import Star from '$lib/components/icons/Star.svelte';
import LightBlub from '$lib/components/icons/LightBlub.svelte';
import Bolt from '$lib/components/icons/Bolt.svelte';
import ArrowRightCircle from '$lib/components/icons/ArrowRightCircle.svelte';
import MarkdownTokens from './MarkdownTokens.svelte';
import type { ComponentType } from 'svelte';
export let token: Token;
export let alert: AlertData;
export let id = '';
export let tokenIdx = 0;
export let onTaskClick: ((event: MouseEvent) => void) | undefined = undefined;
export let onSourceClick: ((event: MouseEvent) => void) | undefined = undefined;
</script>
<!--
Renders the following Markdown as alerts:
> [!NOTE]
> Example note
> [!TIP]
> Example tip
> [!IMPORTANT]
> Example important
> [!CAUTION]
> Example caution
> [!WARNING]
> Example warning
-->
<div class={`border-l-2 pl-2 ${alertStyles[alert.type].border}`}>
<p class={alertStyles[alert.type].text}>
<svelte:component this={alertStyles[alert.type].icon} className="inline-block size-4" />
<b>{alert.type}</b>
</p>
<MarkdownTokens id={`${id}-${tokenIdx}`} tokens={alert.tokens} {onTaskClick} {onSourceClick} />
</div>

View file

@ -31,7 +31,7 @@
{:else if token.text.includes(`<source_id`)} {:else if token.text.includes(`<source_id`)}
<Source {id} {token} onClick={onSourceClick} /> <Source {id} {token} onClick={onSourceClick} />
{:else} {:else}
{token.text} {@html html}
{/if} {/if}
{:else if token.type === 'link'} {:else if token.type === 'link'}
{#if token.tokens} {#if token.tokens}

View file

@ -14,10 +14,13 @@
import CodeBlock from '$lib/components/chat/Messages/CodeBlock.svelte'; import CodeBlock from '$lib/components/chat/Messages/CodeBlock.svelte';
import MarkdownInlineTokens from '$lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte'; import MarkdownInlineTokens from '$lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte';
import KatexRenderer from './KatexRenderer.svelte'; import KatexRenderer from './KatexRenderer.svelte';
import AlertRenderer, { alertComponent } from './AlertRenderer.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte'; import Collapsible from '$lib/components/common/Collapsible.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte'; import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import Source from './Source.svelte'; import Source from './Source.svelte';
import { settings } from '$lib/stores';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -84,6 +87,7 @@
{#if token.raw.includes('```')} {#if token.raw.includes('```')}
<CodeBlock <CodeBlock
id={`${id}-${tokenIdx}`} id={`${id}-${tokenIdx}`}
collapsed={$settings?.collapseCodeBlocks ?? false}
{token} {token}
lang={token?.lang ?? ''} lang={token?.lang ?? ''}
code={token?.text ?? ''} code={token?.text ?? ''}
@ -119,7 +123,7 @@
class="px-3! py-1.5! cursor-pointer border border-gray-100 dark:border-gray-850" class="px-3! py-1.5! cursor-pointer border border-gray-100 dark:border-gray-850"
style={token.align[headerIdx] ? '' : `text-align: ${token.align[headerIdx]}`} style={token.align[headerIdx] ? '' : `text-align: ${token.align[headerIdx]}`}
> >
<div class="flex flex-col gap-1.5 text-left"> <div class="gap-1.5 text-left">
<div class="shrink-0 break-normal"> <div class="shrink-0 break-normal">
<MarkdownInlineTokens <MarkdownInlineTokens
id={`${id}-${tokenIdx}-header-${headerIdx}`} id={`${id}-${tokenIdx}-header-${headerIdx}`}
@ -140,7 +144,7 @@
class="px-3! py-1.5! text-gray-900 dark:text-white w-max border border-gray-100 dark:border-gray-850" class="px-3! py-1.5! text-gray-900 dark:text-white w-max border border-gray-100 dark:border-gray-850"
style={token.align[cellIdx] ? '' : `text-align: ${token.align[cellIdx]}`} style={token.align[cellIdx] ? '' : `text-align: ${token.align[cellIdx]}`}
> >
<div class="flex flex-col break-normal"> <div class="break-normal">
<MarkdownInlineTokens <MarkdownInlineTokens
id={`${id}-${tokenIdx}-row-${rowIdx}-${cellIdx}`} id={`${id}-${tokenIdx}-row-${rowIdx}-${cellIdx}`}
tokens={cell.tokens} tokens={cell.tokens}
@ -170,9 +174,14 @@
</div> </div>
</div> </div>
{:else if token.type === 'blockquote'} {:else if token.type === 'blockquote'}
<blockquote dir="auto"> {@const alert = alertComponent(token)}
<svelte:self id={`${id}-${tokenIdx}`} tokens={token.tokens} {onTaskClick} {onSourceClick} /> {#if alert}
</blockquote> <AlertRenderer {token} {alert} />
{:else}
<blockquote dir="auto">
<svelte:self id={`${id}-${tokenIdx}`} tokens={token.tokens} {onTaskClick} {onSourceClick} />
</blockquote>
{/if}
{: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}>
@ -242,6 +251,7 @@
{:else if token.type === 'details'} {:else if token.type === 'details'}
<Collapsible <Collapsible
title={token.summary} title={token.summary}
open={$settings?.expandDetails ?? false}
attributes={token?.attributes} attributes={token?.attributes}
className="w-full space-y-1" className="w-full space-y-1"
dir="auto" dir="auto"

View file

@ -20,6 +20,7 @@
export let user; export let user;
export let gotoMessage;
export let showPreviousMessage; export let showPreviousMessage;
export let showNextMessage; export let showNextMessage;
export let updateChat; export let updateChat;
@ -57,6 +58,7 @@
: (Object.values(history.messages) : (Object.values(history.messages)
.filter((message) => message.parentId === null) .filter((message) => message.parentId === null)
.map((message) => message.id) ?? [])} .map((message) => message.id) ?? [])}
{gotoMessage}
{showPreviousMessage} {showPreviousMessage}
{showNextMessage} {showNextMessage}
{editMessage} {editMessage}
@ -70,6 +72,7 @@
{messageId} {messageId}
isLastMessage={messageId === history.currentId} isLastMessage={messageId === history.currentId}
siblings={history.messages[history.messages[messageId].parentId]?.childrenIds ?? []} siblings={history.messages[history.messages[messageId].parentId]?.childrenIds ?? []}
{gotoMessage}
{showPreviousMessage} {showPreviousMessage}
{showNextMessage} {showNextMessage}
{updateChat} {updateChat}

View file

@ -58,6 +58,35 @@
} }
} }
const gotoMessage = async (modelIdx, messageIdx) => {
// Clamp messageIdx to ensure it's within valid range
groupedMessageIdsIdx[modelIdx] = Math.max(
0,
Math.min(messageIdx, groupedMessageIds[modelIdx].messageIds.length - 1)
);
// Get the messageId at the specified index
let messageId = groupedMessageIds[modelIdx].messageIds[groupedMessageIdsIdx[modelIdx]];
console.log(messageId);
// Traverse the branch to find the deepest child message
let messageChildrenIds = history.messages[messageId].childrenIds;
while (messageChildrenIds.length !== 0) {
messageId = messageChildrenIds.at(-1);
messageChildrenIds = history.messages[messageId].childrenIds;
}
// Update the current message ID in history
history.currentId = messageId;
// Await UI updates
await tick();
await updateChat();
// Trigger scrolling after navigation
triggerScroll();
};
const showPreviousMessage = async (modelIdx) => { const showPreviousMessage = async (modelIdx) => {
groupedMessageIdsIdx[modelIdx] = Math.max(0, groupedMessageIdsIdx[modelIdx] - 1); groupedMessageIdsIdx[modelIdx] = Math.max(0, groupedMessageIdsIdx[modelIdx] - 1);
@ -224,6 +253,7 @@
messageId={_messageId} messageId={_messageId}
isLastMessage={true} isLastMessage={true}
siblings={groupedMessageIds[modelIdx].messageIds} siblings={groupedMessageIds[modelIdx].messageIds}
gotoMessage={(message, messageIdx) => gotoMessage(modelIdx, messageIdx)}
showPreviousMessage={() => showPreviousMessage(modelIdx)} showPreviousMessage={() => showPreviousMessage(modelIdx)}
showNextMessage={() => showNextMessage(modelIdx)} showNextMessage={() => showNextMessage(modelIdx)}
{updateChat} {updateChat}

View file

@ -5,7 +5,7 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { onMount, tick, getContext } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next'; import type { i18n as i18nType, t } from 'i18next';
const i18n = getContext<Writable<i18nType>>('i18n'); const i18n = getContext<Writable<i18nType>>('i18n');
@ -110,6 +110,7 @@
export let siblings; export let siblings;
export let gotoMessage: Function = () => {};
export let showPreviousMessage: Function; export let showPreviousMessage: Function;
export let showNextMessage: Function; export let showNextMessage: Function;
@ -139,6 +140,8 @@
let editedContent = ''; let editedContent = '';
let editTextAreaElement: HTMLTextAreaElement; let editTextAreaElement: HTMLTextAreaElement;
let messageIndexEdit = false;
let audioParts: Record<number, HTMLAudioElement | null> = {}; let audioParts: Record<number, HTMLAudioElement | null> = {};
let speaking = false; let speaking = false;
let speakingIdx: number | undefined; let speakingIdx: number | undefined;
@ -559,7 +562,7 @@
<div class="flex-auto w-0 pl-1"> <div class="flex-auto w-0 pl-1">
<Name> <Name>
<Tooltip content={model?.name ?? message.model} placement="top-start"> <Tooltip content={model?.name ?? message.model} placement="top-start">
<span class="line-clamp-1"> <span class="line-clamp-1 text-black dark:text-white">
{model?.name ?? message.model} {model?.name ?? message.model}
</span> </span>
</Tooltip> </Tooltip>
@ -739,7 +742,7 @@
{history} {history}
content={message.content} content={message.content}
sources={message.sources} sources={message.sources}
floatingButtons={message?.done} floatingButtons={message?.done && !readOnly}
save={!readOnly} save={!readOnly}
{model} {model}
onTaskClick={async (e) => { onTaskClick={async (e) => {
@ -748,7 +751,9 @@
onSourceClick={async (id, idx) => { onSourceClick={async (id, idx) => {
console.log(id, idx); console.log(id, idx);
let sourceButton = document.getElementById(`source-${message.id}-${idx}`); let sourceButton = document.getElementById(`source-${message.id}-${idx}`);
const sourcesCollapsible = document.getElementById(`collapsible-sources`); const sourcesCollapsible = document.getElementById(
`collapsible-${message.id}`
);
if (sourceButton) { if (sourceButton) {
sourceButton.click(); sourceButton.click();
@ -844,11 +849,50 @@
</svg> </svg>
</button> </button>
<div {#if messageIndexEdit}
class="text-sm tracking-widest font-semibold self-center dark:text-gray-100 min-w-fit" <div
> class="text-sm flex justify-center font-semibold self-center dark:text-gray-100 min-w-fit"
{siblings.indexOf(message.id) + 1}/{siblings.length} >
</div> <input
id="message-index-input-{message.id}"
type="number"
value={siblings.indexOf(message.id) + 1}
min="1"
max={siblings.length}
on:focus={(e) => {
e.target.select();
}}
on:blur={(e) => {
gotoMessage(message, e.target.value - 1);
messageIndexEdit = false;
}}
on:keydown={(e) => {
if (e.key === 'Enter') {
gotoMessage(message, e.target.value - 1);
messageIndexEdit = false;
}
}}
class="bg-transparent font-semibold self-center dark:text-gray-100 min-w-fit outline-hidden"
/>/{siblings.length}
</div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="text-sm tracking-widest font-semibold self-center dark:text-gray-100 min-w-fit"
on:dblclick={async () => {
messageIndexEdit = true;
await tick();
const input = document.getElementById(`message-index-input-${message.id}`);
if (input) {
input.focus();
input.select();
}
}}
>
{siblings.indexOf(message.id) + 1}/{siblings.length}
</div>
{/if}
<button <button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition" class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
@ -1269,7 +1313,7 @@
<Tooltip content={$i18n.t('Delete')} placement="bottom"> <Tooltip content={$i18n.t('Delete')} placement="bottom">
<button <button
type="button" type="button"
id="continue-response-button" id="delete-response-button"
class="{isLastMessage class="{isLastMessage
? '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"

View file

@ -27,6 +27,7 @@
export let siblings; export let siblings;
export let gotoMessage: Function;
export let showPreviousMessage: Function; export let showPreviousMessage: Function;
export let showNextMessage: Function; export let showNextMessage: Function;
@ -38,6 +39,8 @@
let showDeleteConfirm = false; let showDeleteConfirm = false;
let messageIndexEdit = false;
let edit = false; let edit = false;
let editedContent = ''; let editedContent = '';
let messageEditTextAreaElement: HTMLTextAreaElement; let messageEditTextAreaElement: HTMLTextAreaElement;
@ -267,11 +270,52 @@
</svg> </svg>
</button> </button>
<div {#if messageIndexEdit}
class="text-sm tracking-widest font-semibold self-center dark:text-gray-100" <div
> class="text-sm flex justify-center font-semibold self-center dark:text-gray-100 min-w-fit"
{siblings.indexOf(message.id) + 1}/{siblings.length} >
</div> <input
id="message-index-input-{message.id}"
type="number"
value={siblings.indexOf(message.id) + 1}
min="1"
max={siblings.length}
on:focus={(e) => {
e.target.select();
}}
on:blur={(e) => {
gotoMessage(message, e.target.value - 1);
messageIndexEdit = false;
}}
on:keydown={(e) => {
if (e.key === 'Enter') {
gotoMessage(message, e.target.value - 1);
messageIndexEdit = false;
}
}}
class="bg-transparent font-semibold self-center dark:text-gray-100 min-w-fit outline-hidden"
/>/{siblings.length}
</div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="text-sm tracking-widest font-semibold self-center dark:text-gray-100 min-w-fit"
on:dblclick={async () => {
messageIndexEdit = true;
await tick();
const input = document.getElementById(
`message-index-input-${message.id}`
);
if (input) {
input.focus();
input.select();
}
}}
>
{siblings.indexOf(message.id) + 1}/{siblings.length}
</div>
{/if}
<button <button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition" class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
@ -347,7 +391,7 @@
</button> </button>
</Tooltip> </Tooltip>
{#if !isFirstMessage && !readOnly} {#if !readOnly && (!isFirstMessage || siblings.length > 1)}
<Tooltip content={$i18n.t('Delete')} placement="bottom"> <Tooltip content={$i18n.t('Delete')} placement="bottom">
<button <button
class="invisible group-hover:visible p-1 rounded-sm dark:hover:text-white hover:text-black transition" class="invisible group-hover:visible p-1 rounded-sm dark:hover:text-white hover:text-black transition"
@ -398,11 +442,52 @@
</svg> </svg>
</button> </button>
<div {#if messageIndexEdit}
class="text-sm tracking-widest font-semibold self-center dark:text-gray-100" <div
> class="text-sm flex justify-center font-semibold self-center dark:text-gray-100 min-w-fit"
{siblings.indexOf(message.id) + 1}/{siblings.length} >
</div> <input
id="message-index-input-{message.id}"
type="number"
value={siblings.indexOf(message.id) + 1}
min="1"
max={siblings.length}
on:focus={(e) => {
e.target.select();
}}
on:blur={(e) => {
gotoMessage(message, e.target.value - 1);
messageIndexEdit = false;
}}
on:keydown={(e) => {
if (e.key === 'Enter') {
gotoMessage(message, e.target.value - 1);
messageIndexEdit = false;
}
}}
class="bg-transparent font-semibold self-center dark:text-gray-100 min-w-fit outline-hidden"
/>/{siblings.length}
</div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="text-sm tracking-widest font-semibold self-center dark:text-gray-100 min-w-fit"
on:dblclick={async () => {
messageIndexEdit = true;
await tick();
const input = document.getElementById(
`message-index-input-${message.id}`
);
if (input) {
input.focus();
input.select();
}
}}
>
{siblings.indexOf(message.id) + 1}/{siblings.length}
</div>
{/if}
<button <button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition" class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"

View file

@ -46,7 +46,8 @@
model: model model: model
}))} }))}
showTemporaryChatControl={$user.role === 'user' showTemporaryChatControl={$user.role === 'user'
? ($user?.permissions?.chat?.temporary ?? true) ? ($user?.permissions?.chat?.temporary ?? true) &&
!($user?.permissions?.chat?.temporary_enforced ?? false)
: true} : true}
bind:value={selectedModel} bind:value={selectedModel}
/> />

View file

@ -61,10 +61,11 @@
$: selectedModel = items.find((item) => item.value === value) ?? ''; $: selectedModel = items.find((item) => item.value === value) ?? '';
let searchValue = ''; let searchValue = '';
let selectedTag = ''; let selectedTag = '';
let selectedConnectionType = '';
let ollamaVersion = null; let ollamaVersion = null;
let selectedModelIdx = 0; let selectedModelIdx = 0;
const fuse = new Fuse( const fuse = new Fuse(
@ -72,7 +73,7 @@
const _item = { const _item = {
...item, ...item,
modelName: item.model?.name, modelName: item.model?.name,
tags: item.model?.info?.meta?.tags?.map((tag) => tag.name).join(' '), tags: (item.model?.tags ?? []).map((tag) => tag.name).join(' '),
desc: item.model?.info?.meta?.description desc: item.model?.info?.meta?.description
}; };
return _item; return _item;
@ -93,14 +94,61 @@
if (selectedTag === '') { if (selectedTag === '') {
return true; return true;
} }
return item.model?.info?.meta?.tags?.map((tag) => tag.name).includes(selectedTag); return (item.model?.tags ?? []).map((tag) => tag.name).includes(selectedTag);
}) })
: items.filter((item) => { .filter((item) => {
if (selectedTag === '') { if (selectedConnectionType === '') {
return true; return true;
} } else if (selectedConnectionType === 'ollama') {
return item.model?.info?.meta?.tags?.map((tag) => tag.name).includes(selectedTag); return item.model?.owned_by === 'ollama';
}); } else if (selectedConnectionType === 'openai') {
return item.model?.owned_by === 'openai';
} else if (selectedConnectionType === 'direct') {
return item.model?.direct;
}
})
: items
.filter((item) => {
if (selectedTag === '') {
return true;
}
return (item.model?.tags ?? []).map((tag) => tag.name).includes(selectedTag);
})
.filter((item) => {
if (selectedConnectionType === '') {
return true;
} else if (selectedConnectionType === 'ollama') {
return item.model?.owned_by === 'ollama';
} else if (selectedConnectionType === 'openai') {
return item.model?.owned_by === 'openai';
} else if (selectedConnectionType === 'direct') {
return item.model?.direct;
}
});
$: if (selectedTag || selectedConnectionType) {
resetView();
} else {
resetView();
}
const resetView = async () => {
await tick();
const selectedInFiltered = filteredItems.findIndex((item) => item.value === value);
if (selectedInFiltered >= 0) {
// The selected model is visible in the current filter
selectedModelIdx = selectedInFiltered;
} else {
// The selected model is not visible, default to first item in filtered list
selectedModelIdx = 0;
}
await tick();
const item = document.querySelector(`[data-arrow-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
};
const pullModelHandler = async () => { const pullModelHandler = async () => {
const sanitizedModelTag = searchValue.trim().replace(/^ollama\s+(run|pull)\s+/, ''); const sanitizedModelTag = searchValue.trim().replace(/^ollama\s+(run|pull)\s+/, '');
@ -234,7 +282,7 @@
ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false); ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
if (items) { if (items) {
tags = items.flatMap((item) => item.model?.info?.meta?.tags ?? []).map((tag) => tag.name); tags = items.flatMap((item) => item.model?.tags ?? []).map((tag) => tag.name);
// Remove duplicates and sort // Remove duplicates and sort
tags = Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b)); tags = Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b));
@ -262,8 +310,9 @@
bind:open={show} bind:open={show}
onOpenChange={async () => { onOpenChange={async () => {
searchValue = ''; searchValue = '';
selectedModelIdx = 0;
window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0); window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0);
resetView();
}} }}
closeFocus={false} closeFocus={false}
> >
@ -325,29 +374,79 @@
{/if} {/if}
<div class="px-3 mb-2 max-h-64 overflow-y-auto scrollbar-hidden group relative"> <div class="px-3 mb-2 max-h-64 overflow-y-auto scrollbar-hidden group relative">
{#if tags} {#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0}
<div class=" flex w-full sticky"> <div
class=" flex w-full sticky top-0 z-10 bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div <div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent px-1.5 pb-0.5" class="flex gap-1 w-fit text-center text-sm font-medium rounded-full bg-transparent px-1.5 pb-0.5"
bind:this={tagsContainerElement} bind:this={tagsContainerElement}
> >
<button <button
class="min-w-fit outline-none p-1.5 {selectedTag === '' class="min-w-fit outline-none p-1.5 {selectedTag === '' &&
selectedConnectionType === ''
? '' ? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize" : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
on:click={() => { on:click={() => {
selectedConnectionType = '';
selectedTag = ''; selectedTag = '';
}} }}
> >
{$i18n.t('All')} {$i18n.t('All')}
</button> </button>
{#if items.find((item) => item.model?.owned_by === 'ollama') && items.find((item) => item.model?.owned_by === 'openai')}
<button
class="min-w-fit outline-none p-1.5 {selectedConnectionType === 'ollama'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
on:click={() => {
selectedTag = '';
selectedConnectionType = 'ollama';
}}
>
{$i18n.t('Local')}
</button>
<button
class="min-w-fit outline-none p-1.5 {selectedConnectionType === 'openai'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
on:click={() => {
selectedTag = '';
selectedConnectionType = 'openai';
}}
>
{$i18n.t('External')}
</button>
{/if}
{#if items.find((item) => item.model?.direct)}
<button
class="min-w-fit outline-none p-1.5 {selectedConnectionType === 'direct'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
on:click={() => {
selectedTag = '';
selectedConnectionType = 'direct';
}}
>
{$i18n.t('Direct')}
</button>
{/if}
{#each tags as tag} {#each tags as tag}
<button <button
class="min-w-fit outline-none p-1.5 {selectedTag === tag class="min-w-fit outline-none p-1.5 {selectedTag === tag
? '' ? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize" : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
on:click={() => { on:click={() => {
selectedConnectionType = '';
selectedTag = tag; selectedTag = tag;
}} }}
> >
@ -358,7 +457,7 @@
</div> </div>
{/if} {/if}
{#each filteredItems as item, index} {#each filteredItems.filter((item) => !(item.model?.info?.meta?.hidden ?? false)) as item, index}
<button <button
aria-label="model-item" aria-label="model-item"
class="flex w-full text-left font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted {index === class="flex w-full text-left font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted {index ===
@ -366,6 +465,7 @@
? 'bg-gray-100 dark:bg-gray-800 group-hover:bg-transparent' ? 'bg-gray-100 dark:bg-gray-800 group-hover:bg-transparent'
: ''}" : ''}"
data-arrow-selected={index === selectedModelIdx} data-arrow-selected={index === selectedModelIdx}
data-value={item.value}
on:click={() => { on:click={() => {
value = item.value; value = item.value;
selectedModelIdx = index; selectedModelIdx = index;
@ -374,9 +474,9 @@
}} }}
> >
<div class="flex flex-col"> <div class="flex flex-col">
{#if $mobile && (item?.model?.info?.meta?.tags ?? []).length > 0} {#if $mobile && (item?.model?.tags ?? []).length > 0}
<div class="flex gap-0.5 self-start h-full mb-1.5 -translate-x-1"> <div class="flex gap-0.5 self-start h-full mb-1.5 -translate-x-1">
{#each item.model?.info?.meta.tags as tag} {#each item.model?.tags.sort((a, b) => a.name.localeCompare(b.name)) as tag}
<div <div
class=" text-xs font-bold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200" class=" text-xs font-bold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
> >
@ -398,31 +498,37 @@
alt="Model" alt="Model"
class="rounded-full size-5 flex items-center mr-2" class="rounded-full size-5 flex items-center mr-2"
/> />
{item.label}
<div class="flex items-center line-clamp-1">
<div class="line-clamp-1">
{item.label}
</div>
{#if item.model.owned_by === 'ollama' && (item.model.ollama?.details?.parameter_size ?? '') !== ''}
<div class="flex ml-1 items-center translate-y-[0.5px]">
<Tooltip
content={`${
item.model.ollama?.details?.quantization_level
? item.model.ollama?.details?.quantization_level + ' '
: ''
}${
item.model.ollama?.size
? `(${(item.model.ollama?.size / 1024 ** 3).toFixed(1)}GB)`
: ''
}`}
className="self-end"
>
<span
class=" text-xs font-medium text-gray-600 dark:text-gray-400 line-clamp-1"
>{item.model.ollama?.details?.parameter_size ?? ''}</span
>
</Tooltip>
</div>
{/if}
</div>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
{#if item.model.owned_by === 'ollama' && (item.model.ollama?.details?.parameter_size ?? '') !== ''}
<div class="flex ml-1 items-center translate-y-[0.5px]">
<Tooltip
content={`${
item.model.ollama?.details?.quantization_level
? item.model.ollama?.details?.quantization_level + ' '
: ''
}${
item.model.ollama?.size
? `(${(item.model.ollama?.size / 1024 ** 3).toFixed(1)}GB)`
: ''
}`}
className="self-end"
>
<span
class=" text-xs font-medium text-gray-600 dark:text-gray-400 line-clamp-1"
>{item.model.ollama?.details?.parameter_size ?? ''}</span
>
</Tooltip>
</div>
{/if}
</div> </div>
<!-- {JSON.stringify(item.info)} --> <!-- {JSON.stringify(item.info)} -->
@ -496,11 +602,11 @@
</Tooltip> </Tooltip>
{/if} {/if}
{#if !$mobile && (item?.model?.info?.meta?.tags ?? []).length > 0} {#if !$mobile && (item?.model?.tags ?? []).length > 0}
<div <div
class="flex gap-0.5 self-center items-center h-full translate-y-[0.5px] overflow-x-auto scrollbar-none" class="flex gap-0.5 self-center items-center h-full translate-y-[0.5px] overflow-x-auto scrollbar-none"
> >
{#each item.model?.info?.meta.tags as tag} {#each item.model?.tags.sort((a, b) => a.name.localeCompare(b.name)) as tag}
<Tooltip content={tag.name} className="flex-shrink-0"> <Tooltip content={tag.name} className="flex-shrink-0">
<div <div
class=" text-xs font-bold px-1 rounded-sm uppercase bg-gray-500/20 text-gray-700 dark:text-gray-200" class=" text-xs font-bold px-1 rounded-sm uppercase bg-gray-500/20 text-gray-700 dark:text-gray-200"

View file

@ -114,37 +114,21 @@
</div> </div>
</button> </button>
</Menu> </Menu>
{:else if $mobile && ($user.role === 'admin' || $user?.permissions?.chat?.controls)}
<Tooltip content={$i18n.t('Controls')}>
<button
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
on:click={async () => {
await showControls.set(!$showControls);
}}
aria-label="Controls"
>
<div class=" m-auto self-center">
<AdjustmentsHorizontal className=" size-5" strokeWidth="0.5" />
</div>
</button>
</Tooltip>
{/if} {/if}
{#if !$mobile && ($user.role === 'admin' || $user?.permissions?.chat?.controls)} <Tooltip content={$i18n.t('Controls')}>
<Tooltip content={$i18n.t('Controls')}> <button
<button class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition" on:click={async () => {
on:click={async () => { await showControls.set(!$showControls);
await showControls.set(!$showControls); }}
}} aria-label="Controls"
aria-label="Controls" >
> <div class=" m-auto self-center">
<div class=" m-auto self-center"> <AdjustmentsHorizontal className=" size-5" strokeWidth="0.5" />
<AdjustmentsHorizontal className=" size-5" strokeWidth="0.5" /> </div>
</div> </button>
</button> </Tooltip>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('New Chat')}> <Tooltip content={$i18n.t('New Chat')}>
<button <button

View file

@ -38,6 +38,8 @@
export let codeInterpreterEnabled = false; export let codeInterpreterEnabled = false;
export let webSearchEnabled = false; export let webSearchEnabled = false;
export let toolServers = [];
let models = []; let models = [];
const selectSuggestionPrompt = async (p) => { const selectSuggestionPrompt = async (p) => {
@ -196,6 +198,7 @@
bind:codeInterpreterEnabled bind:codeInterpreterEnabled
bind:webSearchEnabled bind:webSearchEnabled
bind:atSelectedModel bind:atSelectedModel
{toolServers}
{transparentBackground} {transparentBackground}
{stopResponse} {stopResponse}
{createMessagePair} {createMessagePair}

View file

@ -245,21 +245,23 @@
</div> </div>
</div> </div>
<div class="pt-2"> {#if $config?.features?.enable_user_webhooks}
<div class="flex flex-col w-full"> <div class="pt-2">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Notification Webhook')}</div> <div class="flex flex-col w-full">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Notification Webhook')}</div>
<div class="flex-1"> <div class="flex-1">
<input <input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden" class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="url" type="url"
placeholder={$i18n.t('Enter your webhook URL')} placeholder={$i18n.t('Enter your webhook URL')}
bind:value={webhookUrl} bind:value={webhookUrl}
required required
/> />
</div>
</div> </div>
</div> </div>
</div> {/if}
</div> </div>
<div class="py-0.5"> <div class="py-0.5">

View file

@ -961,6 +961,7 @@
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Context Length')} {$i18n.t('Context Length')}
{$i18n.t('(Ollama)')}
</div> </div>
<button <button

View file

@ -6,6 +6,7 @@
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte'; import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
export let onDelete = () => {}; export let onDelete = () => {};
export let onSubmit = () => {}; export let onSubmit = () => {};
@ -17,6 +18,7 @@
export let config = {}; export let config = {};
let showConfigModal = false; let showConfigModal = false;
let showDeleteConfirmDialog = false;
</script> </script>
<AddConnectionModal <AddConnectionModal
@ -28,7 +30,9 @@
key, key,
config config
}} }}
{onDelete} onDelete={() => {
showDeleteConfirmDialog = true;
}}
onSubmit={(connection) => { onSubmit={(connection) => {
url = connection.url; url = connection.url;
key = connection.key; key = connection.key;
@ -37,6 +41,14 @@
}} }}
/> />
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
onDelete();
showConfigModal = false;
}}
/>
<div class="flex w-full gap-2 items-center"> <div class="flex w-full gap-2 items-center">
<Tooltip <Tooltip
className="w-full relative" className="w-full relative"

View file

@ -9,6 +9,7 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import AdvancedParams from './Advanced/AdvancedParams.svelte'; import AdvancedParams from './Advanced/AdvancedParams.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
export let saveSettings: Function; export let saveSettings: Function;
export let getModels: Function; export let getModels: Function;
@ -40,7 +41,7 @@
}; };
// Advanced // Advanced
let requestFormat = ''; let requestFormat = null;
let keepAlive: string | null = null; let keepAlive: string | null = null;
let params = { let params = {
@ -70,14 +71,74 @@
num_gpu: null num_gpu: null
}; };
const validateJSON = (json) => {
try {
const obj = JSON.parse(json);
if (obj && typeof obj === 'object') {
return true;
}
} catch (e) {}
return false;
};
const toggleRequestFormat = async () => { const toggleRequestFormat = async () => {
if (requestFormat === '') { if (requestFormat === null) {
requestFormat = 'json'; requestFormat = 'json';
} else { } else {
requestFormat = ''; requestFormat = null;
} }
saveSettings({ requestFormat: requestFormat !== '' ? requestFormat : undefined }); saveSettings({ requestFormat: requestFormat !== null ? requestFormat : undefined });
};
const saveHandler = async () => {
if (requestFormat !== null && requestFormat !== 'json') {
if (validateJSON(requestFormat) === false) {
toast.error($i18n.t('Invalid JSON schema'));
return;
} else {
requestFormat = JSON.parse(requestFormat);
}
}
saveSettings({
system: system !== '' ? system : undefined,
params: {
stream_response: params.stream_response !== null ? params.stream_response : undefined,
function_calling: params.function_calling !== null ? params.function_calling : undefined,
seed: (params.seed !== null ? params.seed : undefined) ?? undefined,
stop: params.stop ? params.stop.split(',').filter((e) => e) : undefined,
temperature: params.temperature !== null ? params.temperature : undefined,
reasoning_effort: params.reasoning_effort !== null ? params.reasoning_effort : undefined,
logit_bias: params.logit_bias !== null ? params.logit_bias : undefined,
frequency_penalty: params.frequency_penalty !== null ? params.frequency_penalty : undefined,
presence_penalty: params.frequency_penalty !== null ? params.frequency_penalty : undefined,
repeat_penalty: params.frequency_penalty !== null ? params.frequency_penalty : undefined,
repeat_last_n: params.repeat_last_n !== null ? params.repeat_last_n : undefined,
mirostat: params.mirostat !== null ? params.mirostat : undefined,
mirostat_eta: params.mirostat_eta !== null ? params.mirostat_eta : undefined,
mirostat_tau: params.mirostat_tau !== null ? params.mirostat_tau : undefined,
top_k: params.top_k !== null ? params.top_k : undefined,
top_p: params.top_p !== null ? params.top_p : undefined,
min_p: params.min_p !== null ? params.min_p : undefined,
tfs_z: params.tfs_z !== null ? params.tfs_z : undefined,
num_ctx: params.num_ctx !== null ? params.num_ctx : undefined,
num_batch: params.num_batch !== null ? params.num_batch : undefined,
num_keep: params.num_keep !== null ? params.num_keep : undefined,
max_tokens: params.max_tokens !== null ? params.max_tokens : undefined,
use_mmap: params.use_mmap !== null ? params.use_mmap : undefined,
use_mlock: params.use_mlock !== null ? params.use_mlock : undefined,
num_thread: params.num_thread !== null ? params.num_thread : undefined,
num_gpu: params.num_gpu !== null ? params.num_gpu : undefined
},
keepAlive: keepAlive ? (isNaN(keepAlive) ? keepAlive : parseInt(keepAlive)) : undefined,
requestFormat: requestFormat !== null ? requestFormat : undefined
});
dispatch('save');
requestFormat =
typeof requestFormat === 'object' ? JSON.stringify(requestFormat, null, 2) : requestFormat;
}; };
onMount(async () => { onMount(async () => {
@ -88,7 +149,12 @@
notificationEnabled = $settings.notificationEnabled ?? false; notificationEnabled = $settings.notificationEnabled ?? false;
system = $settings.system ?? ''; system = $settings.system ?? '';
requestFormat = $settings.requestFormat ?? ''; requestFormat = $settings.requestFormat ?? null;
if (requestFormat !== null && requestFormat !== 'json') {
requestFormat =
typeof requestFormat === 'object' ? JSON.stringify(requestFormat, null, 2) : requestFormat;
}
keepAlive = $settings.keepAlive ?? null; keepAlive = $settings.keepAlive ?? null;
params = { ...params, ...$settings.params }; params = { ...params, ...$settings.params };
@ -270,7 +336,7 @@
<AdvancedParams admin={$user?.role === 'admin'} bind:params /> <AdvancedParams admin={$user?.role === 'admin'} bind:params />
<hr class=" border-gray-100 dark:border-gray-850" /> <hr class=" border-gray-100 dark:border-gray-850" />
<div class=" py-1 w-full justify-between"> <div class=" w-full justify-between">
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Keep Alive')}</div> <div class=" self-center text-xs font-medium">{$i18n.t('Keep Alive')}</div>
@ -302,8 +368,8 @@
</div> </div>
<div> <div>
<div class=" py-1 flex w-full justify-between"> <div class=" flex w-full justify-between">
<div class=" self-center text-sm font-medium">{$i18n.t('Request Mode')}</div> <div class=" self-center text-xs font-medium">{$i18n.t('Request Mode')}</div>
<button <button
class="p-1 px-3 text-xs flex rounded-sm transition" class="p-1 px-3 text-xs flex rounded-sm transition"
@ -311,9 +377,9 @@
toggleRequestFormat(); toggleRequestFormat();
}} }}
> >
{#if requestFormat === ''} {#if requestFormat === null}
<span class="ml-2 self-center"> {$i18n.t('Default')} </span> <span class="ml-2 self-center"> {$i18n.t('Default')} </span>
{:else if requestFormat === 'json'} {:else}
<!-- <svg <!-- <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@ -328,6 +394,16 @@
{/if} {/if}
</button> </button>
</div> </div>
{#if requestFormat !== null}
<div class="flex mt-1 space-x-2">
<Textarea
className="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('e.g. "json" or a JSON schema')}
bind:value={requestFormat}
/>
</div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@ -338,44 +414,7 @@
<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"
on:click={() => { on:click={() => {
saveSettings({ saveHandler();
system: system !== '' ? system : undefined,
params: {
stream_response: params.stream_response !== null ? params.stream_response : undefined,
function_calling:
params.function_calling !== null ? params.function_calling : undefined,
seed: (params.seed !== null ? params.seed : undefined) ?? undefined,
stop: params.stop ? params.stop.split(',').filter((e) => e) : undefined,
temperature: params.temperature !== null ? params.temperature : undefined,
reasoning_effort:
params.reasoning_effort !== null ? params.reasoning_effort : undefined,
logit_bias: params.logit_bias !== null ? params.logit_bias : undefined,
frequency_penalty:
params.frequency_penalty !== null ? params.frequency_penalty : undefined,
presence_penalty:
params.frequency_penalty !== null ? params.frequency_penalty : undefined,
repeat_penalty:
params.frequency_penalty !== null ? params.frequency_penalty : undefined,
repeat_last_n: params.repeat_last_n !== null ? params.repeat_last_n : undefined,
mirostat: params.mirostat !== null ? params.mirostat : undefined,
mirostat_eta: params.mirostat_eta !== null ? params.mirostat_eta : undefined,
mirostat_tau: params.mirostat_tau !== null ? params.mirostat_tau : undefined,
top_k: params.top_k !== null ? params.top_k : undefined,
top_p: params.top_p !== null ? params.top_p : undefined,
min_p: params.min_p !== null ? params.min_p : undefined,
tfs_z: params.tfs_z !== null ? params.tfs_z : undefined,
num_ctx: params.num_ctx !== null ? params.num_ctx : undefined,
num_batch: params.num_batch !== null ? params.num_batch : undefined,
num_keep: params.num_keep !== null ? params.num_keep : undefined,
max_tokens: params.max_tokens !== null ? params.max_tokens : undefined,
use_mmap: params.use_mmap !== null ? params.use_mmap : undefined,
use_mlock: params.use_mlock !== null ? params.use_mlock : undefined,
num_thread: params.num_thread !== null ? params.num_thread : undefined,
num_gpu: params.num_gpu !== null ? params.num_gpu : undefined
},
keepAlive: keepAlive ? (isNaN(keepAlive) ? keepAlive : parseInt(keepAlive)) : undefined
});
dispatch('save');
}} }}
> >
{$i18n.t('Save')} {$i18n.t('Save')}

View file

@ -30,15 +30,21 @@
// Interface // Interface
let defaultModelId = ''; let defaultModelId = '';
let showUsername = false; let showUsername = false;
let richTextInput = true;
let largeTextAsFile = false;
let notificationSound = true; let notificationSound = true;
let richTextInput = true;
let promptAutocomplete = false;
let largeTextAsFile = false;
let landingPageMode = ''; let landingPageMode = '';
let chatBubble = true; let chatBubble = true;
let chatDirection: 'LTR' | 'RTL' = 'LTR'; let chatDirection: 'LTR' | 'RTL' = 'LTR';
let ctrlEnterToSend = false; let ctrlEnterToSend = false;
let collapseCodeBlocks = false;
let expandDetails = false;
let imageCompression = false; let imageCompression = false;
let imageCompressionSize = { let imageCompressionSize = {
width: '', width: '',
@ -55,11 +61,26 @@
let webSearch = null; let webSearch = null;
const toggleExpandDetails = () => {
expandDetails = !expandDetails;
saveSettings({ expandDetails });
};
const toggleCollapseCodeBlocks = () => {
collapseCodeBlocks = !collapseCodeBlocks;
saveSettings({ collapseCodeBlocks });
};
const toggleSplitLargeChunks = async () => { const toggleSplitLargeChunks = async () => {
splitLargeChunks = !splitLargeChunks; splitLargeChunks = !splitLargeChunks;
saveSettings({ splitLargeChunks: splitLargeChunks }); saveSettings({ splitLargeChunks: splitLargeChunks });
}; };
const togglePromptAutocomplete = async () => {
promptAutocomplete = !promptAutocomplete;
saveSettings({ promptAutocomplete: promptAutocomplete });
};
const togglesScrollOnBranchChange = async () => { const togglesScrollOnBranchChange = async () => {
scrollOnBranchChange = !scrollOnBranchChange; scrollOnBranchChange = !scrollOnBranchChange;
saveSettings({ scrollOnBranchChange: scrollOnBranchChange }); saveSettings({ scrollOnBranchChange: scrollOnBranchChange });
@ -225,8 +246,12 @@
voiceInterruption = $settings.voiceInterruption ?? false; voiceInterruption = $settings.voiceInterruption ?? false;
richTextInput = $settings.richTextInput ?? true; richTextInput = $settings.richTextInput ?? true;
promptAutocomplete = $settings.promptAutocomplete ?? false;
largeTextAsFile = $settings.largeTextAsFile ?? false; largeTextAsFile = $settings.largeTextAsFile ?? false;
collapseCodeBlocks = $settings.collapseCodeBlocks ?? false;
expandDetails = $settings.expandDetails ?? false;
landingPageMode = $settings.landingPageMode ?? ''; landingPageMode = $settings.landingPageMode ?? '';
chatBubble = $settings.chatBubble ?? true; chatBubble = $settings.chatBubble ?? true;
widescreenMode = $settings.widescreenMode ?? false; widescreenMode = $settings.widescreenMode ?? false;
@ -548,6 +573,30 @@
</div> </div>
</div> </div>
{#if $config?.features?.enable_autocomplete_generation && richTextInput}
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Prompt Autocompletion')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
togglePromptAutocomplete();
}}
type="button"
>
{#if promptAutocomplete === 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>
{/if}
<div> <div>
<div class=" py-0.5 flex w-full justify-between"> <div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs"> <div class=" self-center text-xs">
@ -570,6 +619,46 @@
</div> </div>
</div> </div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">{$i18n.t('Always Collapse Code Blocks')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
toggleCollapseCodeBlocks();
}}
type="button"
>
{#if collapseCodeBlocks === 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>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs">{$i18n.t('Always Expand Details')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
toggleExpandDetails();
}}
type="button"
>
{#if expandDetails === 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>
<div> <div>
<div class=" py-0.5 flex w-full justify-between"> <div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs"> <div class=" self-center text-xs">

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