diff --git a/.gitignore b/.gitignore index 32271f8087..07494bd151 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +x.py +yarn.lock .DS_Store node_modules /build @@ -12,7 +14,8 @@ vite.config.ts.timestamp-* __pycache__/ *.py[cod] *$py.class - +.nvmrc +CLAUDE.md # C extensions *.so diff --git a/CHANGELOG.md b/CHANGELOG.md index 919baf244a..bad49c859c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,120 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.19] - 2025-08-09 + +### Added + +- ✨ **Modernized Sidebar and Major UI Refinements**: The main navigation sidebar has been completely redesigned with a modern, cleaner aesthetic, featuring a sticky header and footer to keep key controls accessible. Core sidebar logic, like the pinned models list, was also refactored into dedicated components for better performance and maintainability. +- 🪄 **Guided Response Regeneration**: The "Regenerate" button has been transformed into a powerful new menu. You can now guide the AI's next attempt by suggesting changes in a text prompt, or use one-click options like "Try Again," "Add Details," or "More Concise" to instantly refine and reshape the response to better fit your needs. +- 🛠️ **Improved Tool Call Handling for GPT-OSS Models**: Implemented robust handling for tool calls specifically for GPT-OSS models, ensuring proper function execution and integration. +- 🛑 **Stop Button for Merge Responses**: Added a dedicated stop button to immediately halt the generation of merged AI responses, providing users with more control over ongoing outputs. +- 🔄 **Experimental SCIM 2.0 Support**: Implemented SCIM 2.0 (System for Cross-domain Identity Management) protocol support, enabling enterprise-grade automated user and group provisioning from identity providers like Okta, Azure AD, and Google Workspace for seamless user lifecycle management. Configuration is managed securely via environment variables. +- 🗂️ **Amazon S3 Vector Support**: You can now use Amazon S3 Vector as a high-performance vector database for your Retrieval-Augmented Generation (RAG) workflows. This provides a scalable, cloud-native storage option for users deeply integrated into the AWS ecosystem, simplifying infrastructure and enabling enterprise-scale knowledge management. +- 🗄️ **Oracle 23ai Vector Search Support**: Added support for Oracle 23ai's new vector search capabilities as a supported vector database, providing a robust and scalable option for managing large-scale documents and integrating vector search with existing business data at the database level. +- ⚡ **Qdrant Performance and Configuration Enhancements**: The Qdrant client has been significantly improved with faster data retrieval logic for 'get' and 'query' operations. New environment variables ('QDRANT_TIMEOUT', 'QDRANT_HNSW_M') provide administrators with finer control over query timeouts and HNSW index parameters, enabling better performance tuning for large-scale deployments. +- 🔐 **Encrypted SQLite Database with SQLCipher**: You can now encrypt your entire SQLite database at rest using SQLCipher. By setting the 'DATABASE_TYPE' to 'sqlite+sqlcipher' and providing a 'DATABASE_PASSWORD', all data is transparently encrypted, providing an essential security layer for protecting sensitive information in self-hosted deployments. Note that this requires additional system libraries and the 'sqlcipher3-wheels' Python package. +- 🚀 **Efficient Redis Connection Management**: Implemented a shared connection pool cache to reuse Redis connections, dramatically reducing the number of active clients. This prevents connection exhaustion errors, improves performance, and ensures greater stability in high-concurrency deployments and those using Redis Sentinel. +- ⚡ **Batched Response Streaming for High Performance**: Dramatically improve performance and stability during high-speed response streaming by batching multiple tokens together before sending them to the client. A new 'Stream Delta Chunk Size' advanced parameter can be set per-model or in user/chat settings, significantly reducing CPU load on the server, Redis, and client, and preventing connection issues in high-concurrency environments. +- ⚙️ **Global Batched Streaming Configuration**: Administrators can now set a system-wide default for response streaming using the new 'CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE' environment variable. This allows for global performance tuning, while still letting per-model or per-chat settings override the default for more granular control. +- 🔎 **Advanced Chat Search with Status Filters**: Quickly find any conversation with powerful new search filters. You can now instantly narrow down your chats using prefixes like 'pinned:true', 'shared:true', and 'archived:true' directly in the search bar. An intelligent dropdown menu assists you by suggesting available filter options as you type, streamlining your workflow and making chat management more efficient than ever. +- 🛂 **Granular Chat Controls Permissions**: Administrators can now manage chat settings with greater detail. The main "Chat Controls" permission now acts as a master switch, while new granular toggles for "Valves", "System Prompts", and "Advanced Parameters" allow for more specific control over which sections are visible to users inside the panel. +- ✍️ **Formatting Toolbar for Chat Input**: Introduced a dedicated formatting toolbar for the rich text chat input field, providing users with more accessible options for text styling and editing, configurable via interface settings. +- 📑 **Tabbed View for Multi-Model Responses**: You can now enable a new tabbed interface to view responses from multiple models. Instead of side-scrolling cards, this compact view organizes each model's response into its own tab, making it easier to compare outputs and saving vertical space. This feature can be toggled on or off in Interface settings. +- ↕️ **Reorder Pinned Models via Drag-and-Drop**: You can now organize your pinned models in the sidebar by simply dragging and dropping them into your preferred order. This custom layout is saved automatically, giving you more flexible control over your workspace. +- 📌 **Quick Model Unpin Shortcut**: You can now quickly unpin a model by holding the Shift key and hovering over it to reveal an instant unpin button, streamlining your workspace customization. +- ⚡ **Improved Chat Input Performance**: The chat input is now significantly more responsive, especially when pasting or typing large amounts of text. This was achieved by implementing a debounce mechanism for the auto-save feature, which prevents UI lag and ensures a smooth, uninterrupted typing experience. +- ✍️ **Customizable Floating Quick Actions with Tool Support**: Take full control of your text interaction workflow with new customizable floating quick actions. In Settings, you can create, edit, or disable these actions and even integrate tools using the '{{TOOL:tool_id}}' syntax in your prompts, enabling powerful one-click automations on selected text. This is in addition to using placeholders like '{{CONTENT}}' and '{{INPUT_CONTENT}}' for custom text transformations. +- 🔒 **Admin Workspace Privacy Control**: Introduced the 'ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS' environment variable (defaults to 'True') allowing administrators to control their access privileges to workspace items (Knowledge, Models, Prompts, Tools). When disabled, administrators adhere to the same access control rules as regular users, enhancing data separation for multi-tenant deployments. +- 🗄️ **Comprehensive Model Configuration Management**: Administrators can now export the entire model configuration to a file and use a new declarative sync endpoint to manage models in bulk. This powerful feature enables seamless backups, migrations, and state replication across multiple instances. +- 📦 **Native Redis Cluster Mode Support**: Added full support for connecting to Redis in cluster mode, allowing for scalable and highly available Redis deployments beyond Sentinel-managed setups. New environment variables 'REDIS_CLUSTER' and 'WEBSOCKET_REDIS_CLUSTER' enable the use of 'redis.cluster.RedisCluster' clients. +- 📊 **Granular OpenTelemetry Metrics Configuration**: Introduced dedicated environment variables and enhanced configuration options for OpenTelemetry metrics, allowing for separate OTLP endpoints, basic authentication credentials, and protocol (HTTP/gRPC) specifically for metrics export, independent of trace settings. This provides greater flexibility for integrating with diverse observability stacks. +- 🪵 **Granular OpenTelemetry Logging Configuration**: Enhanced the OpenTelemetry logging integration by introducing dedicated environment variables for logs, allowing separate OTLP endpoints, basic authentication credentials, and protocol (HTTP/gRPC) specifically for log export, independent of general OTel settings. The application's default Python logger now leverages this configuration to automatically send logs to your OTel endpoint when enabled via 'ENABLE_OTEL_LOGS'. +- 📁 **Enhanced Folder Chat Management with Sorting and Time Blocks**: The chat list within folders now supports comprehensive sorting options by title and updated time, along with intelligent time-based grouping (e.g., "Today," "Yesterday") similar to the main chat view, making navigation and organization of project-specific conversations significantly easier. +- ⚙️ **Configurable Datalab Marker API & Advanced Processing Options**: Enhanced Datalab Marker API integration, allowing administrators to configure custom API base URLs for self-hosting and to specify comprehensive processing options via a new 'additional_config' JSON parameter. This replaces the deprecated language selection feature and provides granular control over document extraction, with streamlined API endpoint resolution for more robust self-hosted deployments. +- 🧑💼 **Export All Users to CSV**: Administrators can now export a complete list of all users to a CSV file directly from the Admin Panel's database settings. This provides a simple, one-click way to generate user data for auditing, reporting, or management purposes. +- 🛂 **Customizable OAuth 'sub' Claim**: Administrators can now use the 'OAUTH_SUB_CLAIM_OVERRIDE' environment variable to specify which claim from the identity provider should be used as the unique user identifier ('sub'). This provides greater flexibility and control for complex enterprise authentication setups where modifying the IDP's default claims is not possible. +- 👁️ **Password Visibility Toggle for Input Fields**: Password fields across the application (login, registration, user management, and account settings) now utilize a new 'SensitiveInput' component, providing a consistent toggle to reveal/hide passwords for improved usability and security. +- 🛂 **Optional "Confirm Password" on Sign-Up**: To help prevent password typos during account creation, administrators can now enable a "Confirm Password" field on the sign-up page. This feature is disabled by default and can be activated via an environment variable for enhanced user experience. +- 💬 **View Full Chat from User Feedback**: Administrators can now easily navigate to the full conversation associated with a user feedback entry directly from the feedback modal, streamlining the review and troubleshooting process. +- 🎚️ **Intuitive Hybrid Search BM25-Weight Slider**: The numerical input for the BM25-Weight parameter in Hybrid Search has been replaced with an interactive slider, offering a more intuitive way to adjust the balance between lexical and semantic search. A "Default/Custom" toggle and clearer labels enhance usability and understanding of this key parameter. +- ⚙️ **Enhanced Bulk Function Synchronization**: The API endpoint for synchronizing functions has been significantly improved to reliably handle bulk updates. This ensures that importing and managing large libraries of functions is more robust and error-free for administrators. +- 🖼️ **Option to Disable Image Compression in Channels**: Introduced a new setting under Interface options to allow users to force-disable image compression specifically for images posted in channels, ensuring higher resolution for critical visual content. +- 🔗 **Custom CORS Scheme Support**: Introduced a new environment variable 'CORS_ALLOW_CUSTOM_SCHEME' that allows administrators to define custom URL schemes (e.g., 'app://') for CORS origins, enabling greater flexibility for local development or desktop client integrations. +- ♿ **Translatable and Accessible Banners**: Enhanced banner elements with translatable badge text and proper ARIA attributes (aria-label, aria-hidden) for SVG icons, significantly improving accessibility and screen reader compatibility. +- ⚠️ **OAuth Configuration Warning for Missing OPENID_PROVIDER_URL**: Added a proactive startup warning that notifies administrators when OAuth providers (Google, Microsoft, or GitHub) are configured but the essential 'OPENID_PROVIDER_URL' environment variable is missing. This prevents silent OAuth logout failures and guides administrators to complete their setup correctly. +- ♿ **Major Accessibility Enhancements**: Key parts of the interface have been made significantly more accessible. The user profile menu is now fully navigable via keyboard, essential controls in the Playground now include proper ARIA labels for screen readers, and decorative images have been hidden from assistive technologies to reduce audio clutter. Menu buttons also feature enhanced accessibility with 'aria-label', 'aria-hidden' for SVGs, and 'aria-pressed' for toggle buttons. +- ⚙️ **General Backend Refactoring**: Implemented various backend improvements to enhance performance, stability, and security, ensuring a more resilient and reliable platform for all users, including refining logging output to be cleaner and more efficient by conditionally including 'extra_json' fields and improving consistent metadata handling in vector database operations, and laying preliminary scaffolding for future analytics features. +- 🌐 **Localization & Internationalization Improvements**: Refined and expanded translations for Catalan, Danish, Korean, Persian, Polish, Simplified Chinese, and Spanish, ensuring a more fluent and native experience for global users across all supported languages. + +### Fixed + +- 🛡️ **Hardened Channel Message Security**: Fixed a key permission flaw that allowed users with channel access to edit or delete messages belonging to others. The system now correctly enforces that users can only modify their own messages, protecting data integrity in shared channels. +- 🛡️ **Hardened OAuth Security by Removing JWT from URL**: Fixed a critical security vulnerability where the authentication token was exposed in the URL after a successful OAuth login. The token is now transferred via a browser cookie, preventing potential leaks through browser history or server logs and protecting user sessions. +- 🛡️ **Hardened Chat Completion API Security**: The chat completion API endpoint now includes an explicit ownership check, ensuring non-admin users cannot access chats that do not belong to them and preventing potential unauthorized access. +- 🛠️ **Resilient Model Loading**: Fixed an issue where a failure in loading the model list (e.g., from a misconfigured provider) would prevent the entire user interface, including the admin panel, from loading. The application now gracefully handles these errors, ensuring the UI remains accessible. +- 🔒 **Resolved FIPS Self-Test Failure**: Fixed a critical issue that prevented Open WebUI from running on FIPS-compliant systems, specifically resolving the "FATAL FIPS SELFTEST FAILURE" error related to OpenSSL and SentenceTransformers, restoring compatibility with secure environments. +- 📦 **Redis Cluster Connection Restored**: Fixed an issue where the backend was unable to connect to Redis in cluster mode, now ensuring seamless integration with scalable Redis cluster deployments. +- 📦 **PGVector Connection Stability**: Fixed an issue where read-only operations could leave database transactions idle, preventing potential connection errors and improving overall database stability and resource management. +- 🛠️ **OpenAPI Tool Integration for Array Parameters Fixed**: Resolved a critical bug where external tools using array parameters (e.g., for tags) would fail when used with OpenAI models. The system now correctly generates the required 'items' property in the function schema, restoring functionality and preventing '400 Bad Request' errors. +- 🛠️ **Tool Creation for Users Restored**: Fixed a bug in the code editor where status messages were incorrectly prepended to tool scripts, causing a syntax error upon saving. All authorized users can now reliably create and save new tools. +- 📁 **Folder Knowledge Processing Restored**: Fixed a bug where files uploaded to folder and model knowledge bases were not being extracted or analyzed for Retrieval-Augmented Generation (RAG) when the 'Max Upload Count' setting was empty, ensuring seamless document processing and knowledge augmentation. +- 🧠 **Custom Model Knowledge Base Updates Recognized**: Fixed a bug where custom models linked to to knowledge bases did not automatically recognize newly added files to those knowledge bases. Models now correctly incorporate the latest information from updated knowledge collections. +- 📦 **Comprehensive Redis Key Prefixing**: Corrected hardcoded prefixes to ensure the REDIS_KEY_PREFIX is now respected across all WebSocket and task management keys. This prevents data collisions in multi-instance deployments and improves compatibility with Redis cluster mode. +- ✨ **More Descriptive OpenAI Router Errors**: The OpenAI-compatible API router now propagates detailed upstream error messages instead of returning a generic 'Bad Request'. This provides clear, actionable feedback for developers and API users, making it significantly easier to debug and resolve issues with model requests. +- 🔐 **Hardened OIDC Signout Flow**: The OpenID Connect signout process now verifies that the 'OPENID_PROVIDER_URL' is configured before attempting to communicate with it, preventing potential errors and ensuring a more reliable logout experience. +- 🍓 **Raspberry Pi Compatibility Restored**: Pinned the pyarrow library to version 20.0.0, resolving an "Illegal Instruction" crash on ARM-based devices like the Raspberry Pi and ensuring stable operation on this hardware. +- 📁 **Folder System Prompt Variables Restored**: Fixed a bug where prompt variables (e.g., '{{CURRENT_DATETIME}}') were not being rendered in Folder-level System Prompts. This restores an important capability for creating dynamic, context-aware instructions for all chats within a project folder. +- 📝 **Note Access in Knowledge Retrieval Fixed**: Corrected a permission oversight in knowledge retrieval, ensuring users can always use their own notes as a source for RAG without needing explicit sharing permissions. +- 🤖 **Title Generation Compatibility for GPT-5 Models**: Added support for 'gpt-5' models in the payload handler, which correctly converts the deprecated 'max_tokens' parameter to 'max_completion_tokens'. This resolves title generation failures and ensures seamless operation with the latest generation of models. +- ⚙️ **Correct API 'finish_reason' in Streaming Responses**: Fixed an issue where intermediate 'reasoning_content' chunks in streaming API responses incorrectly reported a 'finish_reason' of 'stop'. The 'finish_reason' is now correctly set to 'null' for these chunks, ensuring compatibility with third-party applications that rely on this field. +- 📈 **Evaluation Pages Stability**: Resolved a crash on the Leaderboard and Feedbacks pages when processing legacy feedback entries that were missing a 'rating' field. The system now gracefully handles this older data, ensuring both pages load reliably for all users. +- 🤝 **Reliable Collaborative Session Cleanup**: Fixed an asynchronous bug in the real-time collaboration engine that prevented document sessions from being properly cleaned up after all users had left. This ensures greater stability and resource management for features like Collaborative Notes. +- 🧠 **Enhanced Memory Stability and Security**: Refactored memory update and delete operations to strictly enforce user ownership, preventing potential data integrity issues. Additionally, improved error handling for memory queries now provides clearer feedback when no memories exists. +- 🧑⚖️ **Restored Admin Access to User Feedback**: Fixed a permission issue that blocked administrators from viewing or editing user feedback they didn't create, ensuring they can properly manage all evaluations across the platform. +- 🔐 **PGVector Encryption Fix for Metadata**: Corrected a SQL syntax error in the experimental 'PGVECTOR_PGCRYPTO' feature that prevented encrypted metadata from being saved. Document uploads to encrypted PGVector collections now work as intended. +- 🔍 **Serply Web Search Integration Restored**: Fixed an issue where incorrect parameters were passed to the Serply web search engine, restoring its functionality for RAG and web search workflows. +- 🔍 **Resilient Web Search Processing**: Web search retrieval now gracefully handles search results that are missing a 'snippet', preventing crashes and ensuring that RAG workflows complete successfully even with incomplete data from search engines. +- 🖼️ **Table Pasting in Rich Text Input Displayed Correctly**: Fixed an issue where pasting table text into the rich text input would incorrectly display it as code. Tables are now properly rendered as expected, improving content formatting and user experience. +- ✍️ **Rich Text Input TypeError Resolution**: Addressed a potential 'TypeError: ue.getWordAtDocPos is not a function' in 'MessageInput.svelte' by refactoring how the 'getWordAtDocPos' function is accessed and referenced from 'RichTextInput.svelte', ensuring stable rich text input behavior, especially after production restarts. +- ✏️ **Manual Code Block Creation in Chat Restored**: Fixed an issue where typing three backticks and then pressing Shift+Enter would incorrectly remove the backticks when "Enter to Send" mode was active. This ensures users can reliably create multi-line code blocks manually. +- 🎨 **Consistent Dark Mode Background**: Fixed an issue where the application background could incorrectly flash or remain white during page loads and refreshes in dark mode, ensuring a seamless and consistent visual experience. +- 🎨 **'Her' Theme Rendering Fixed**: Corrected a bug that caused the "Her" theme to incorrectly render as a dark theme in some situations. The theme now reliably applies its intended light appearance across all sessions. +- 📜 **Corrected Markdown Table Line Break Rendering**: Fixed an issue where line breaks ('') within Markdown tables were displayed as raw HTML instead of being rendered correctly. This ensures that tables with multi-line cell content are now displayed as intended. +- 🚦 **Corrected App Configuration for Pending Users**: Fixed an issue where users awaiting approval could incorrectly load the full application interface, leading to a confusing or broken UI. This ensures that only fully approved users receive the standard app 'config', resulting in a smoother and more reliable onboarding experience. +- 🔄 **Chat Cloning Now Includes Tags, Folder Status, and Pinned Status**: When cloning a chat or shared chat, its associated tags, folder organization, and pinned status are now correctly replicated, ensuring consistent chat management. +- ⚙️ **Enhanced Backend Reliability**: Resolved a potential crash in knowledge base retrieval when referencing a deleted note. Additionally, chat processing was refactored to ensure model information is saved more reliably, enhancing overall system stability. +- ⚙️ **Floating 'Ask/Explain' Modal Stability**: Fixed an issue that spammed the console with errors when navigating away while a model was generating a response in the floating 'Ask' or 'Explain' modals. In-flight requests are now properly cancelled, improving application stability. +- ⚡ **Optimized User Count Checks**: Improved performance for user count and existence checks across the application by replacing resource-intensive 'COUNT' queries with more efficient 'EXISTS' queries, reducing database load. +- 🔐 **Hardened OpenTelemetry Exporter Configuration**: The OTLP HTTP exporter no longer uses a potentially insecure explicit flag, improving security by relying on the connection URL's protocol (HTTP/HTTPS) to ensure transport safety. +- 📱 **Mobile User Menu Closing Behavior Fixed**: Resolved an issue where the user menu would remain open on mobile devices after selecting an option, ensuring the menu correctly closes and returns focus to the main interface for a smoother mobile experience. +- 📱 **OnBoarding Page Display Fixed on Mobile**: Resolved an issue where buttons on the OnBoarding page were not consistently visible on certain mobile browsers, ensuring a functional and complete user experience across devices. +- ↕️ **Improved Pinned Models Drag-and-Drop Behavior**: The drag-and-drop functionality for reordering pinned models is now explicitly disabled on mobile devices, ensuring better usability and preventing potential UI conflicts or unexpected behavior. +- 📱 **PWA Rotation Behavior Corrected**: The Progressive Web App now correctly respects the device's screen orientation lock, preventing unwanted rotation and ensuring a more native mobile experience. +- ✏️ **Improved Chat Title Editing Behavior**: Changes to a chat title are now reliably saved when the user clicks away or presses Enter, replacing a less intuitive behavior that could accidentally discard edits. This makes renaming chats a smoother and more predictable experience. +- ✏️ **Underscores Allowed in Prompt Commands**: Fixed the validation for prompt commands to correctly allow the use of underscores ('\_'), aligning with documentation examples and improving flexibility in naming custom prompts. +- 💡 **Title Generation Button Behavior Fixed**: Resolved an issue where clicking the "Generate Title" button while editing a chat or note title would incorrectly save the title before generation could start. The focus is now managed correctly, ensuring a smooth and predictable user experience. +- ✏️ **Consistent Chat Input Height**: Fixed a minor visual bug where the chat input field's height would change slightly when toggling the "Rich Text Input for Chat" setting, ensuring a more stable and consistent layout. +- 🙈 **Admin UI Toggle Stability**: Fixed a visual glitch in the Admin settings where toggle switches could briefly display an incorrect state on page load, ensuring the UI always accurately reflects the saved settings. +- 🙈 **Community Sharing Button Visibility**: The "Share to Community" button on the feedback page is now correctly hidden when the Enable Community Sharing feature is disabled in the admin settings, ensuring the UI respects the configured sharing policy. +- 🙈 **"Help Us Translate" Link Visibility**: The "Help us translate" link in settings is now correctly hidden in deployments with specific license configurations, ensuring a cleaner interface for enterprise users. +- 🔗 **Robust Tool Server URL Handling**: Fixed an issue where providing a full URL for a tool server's OpenAPI specification resulted in an invalid path. The system now correctly handles both absolute URLs and relative paths, improving configuration flexibility. +- 🔧 **Improved Azure URL Detection**: The logic for identifying Azure OpenAI endpoints has been made more robust, ensuring all valid Azure URLs are now correctly detected for a smoother connection setup. +- ⚙️ **Corrected Direct Connection Save Logic**: Fixed a bug in the Admin Connections settings page by removing a redundant save action for 'Direct Connections', leading to more reliable and predictable behavior when updating settings. +- 🔗 **Corrected "Discover" Links**: The "Discover" links for models, prompts, tools, and functions now point to their specific, relevant pages on openwebui.com, improving content discovery for users. +- ⏱️ **Refined Display of AI Thought Duration**: Adjusted the display logic for AI thought (reasoning) durations to more accurately show very short thought times as "less than a second," improving clarity in AI process feedback. +- 📜 **Markdown Line Break Rendering Refinement**: Improved handling of line breaks within Markdown rendering for better visual consistency. +- 🛠️ **Corrected OpenTelemetry Docker Compose Example**: The docker-compose.otel.yaml file has been fixed and enhanced by removing duplicates, adding necessary environment variables, and hardening security settings, ensuring a more reliable out-of-box observability setup. +- 🛠️ **Development Script CORS Fix**: Corrected the CORS origin URL in the local development script (dev.sh) by removing the trailing slash, ensuring a more reliable and consistent setup for developers. +- ⬆️ **OpenTelemetry Libraries Updated**: Upgraded all OpenTelemetry-related libraries to their latest versions, ensuring better performance, stability, and compatibility for observability. + +### Changed + +- ❗ **Docling Integration Upgraded to v1 API (Breaking Change)**: The integration with the Docling document processing engine has been updated to its new, stable '/v1' API. This is required for compatibility with Docling version 1.0.0 and newer. As a result, older versions of Docling are no longer supported. Users who rely on Docling for document ingestion **must upgrade** their docling-serve instance to ensure continued functionality. +- 🗣️ **Admin-First Whisper Language Priority**: The global WHISPER_LANGUAGE setting now acts as a strict override for audio transcriptions. If set, it will be used for all speech-to-text tasks, ignoring any language specified by the user on a per-request basis. This gives administrators more control over transcription consistency. +- ✂️ **Datalab Marker API Language Selection Removed**: The separate language selection option for the Datalab Marker API has been removed, as its functionality is now integrated and superseded by the more comprehensive 'additional_config' parameter. Users should transition to using 'additional_config' for relevant language and processing settings. +- 📄 **Documentation and Releases Links Visibility**: The "Documentation" and "Releases" links in the user menu are now visible only to admin users, streamlining the user interface for non-admin roles. + ## [0.6.18] - 2025-07-19 ### Fixed diff --git a/README.md b/README.md index 12ccf93fe1..057b8559b8 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ For more information, be sure to check out our [Open WebUI Documentation](https: - 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users. +- 🔄 **SCIM 2.0 Support**: Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management. + - 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices. - 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface. diff --git a/backend/dev.sh b/backend/dev.sh index 0164c1940e..504b8f7554 100755 --- a/backend/dev.sh +++ b/backend/dev.sh @@ -1,3 +1,3 @@ -export CORS_ALLOW_ORIGIN=http://localhost:5173/ +export CORS_ALLOW_ORIGIN="http://localhost:5173" PORT="${PORT:-8080}" uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index bf4325417d..b10dee5b63 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -7,7 +7,7 @@ import redis from datetime import datetime from pathlib import Path -from typing import Generic, Optional, TypeVar +from typing import Generic, Union, Optional, TypeVar from urllib.parse import urlparse import requests @@ -168,9 +168,19 @@ class PersistentConfig(Generic[T]): self.config_path = config_path self.env_value = env_value self.config_value = get_config_value(config_path) + if self.config_value is not None and ENABLE_PERSISTENT_CONFIG: - log.info(f"'{env_name}' loaded from the latest database entry") - self.value = self.config_value + if ( + self.config_path.startswith("oauth.") + and not ENABLE_OAUTH_PERSISTENT_CONFIG + ): + log.info( + f"Skipping loading of '{env_name}' as OAuth persistent config is disabled" + ) + self.value = env_value + else: + log.info(f"'{env_name}' loaded from the latest database entry") + self.value = self.config_value else: self.value = env_value @@ -213,13 +223,14 @@ class PersistentConfig(Generic[T]): class AppConfig: _state: dict[str, PersistentConfig] - _redis: Optional[redis.Redis] = None + _redis: Union[redis.Redis, redis.cluster.RedisCluster] = None _redis_key_prefix: str def __init__( self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = [], + redis_cluster: Optional[bool] = False, redis_key_prefix: str = "open-webui", ): super().__setattr__("_state", {}) @@ -227,7 +238,12 @@ class AppConfig: if redis_url: super().__setattr__( "_redis", - get_redis_connection(redis_url, redis_sentinels, decode_responses=True), + get_redis_connection( + redis_url, + redis_sentinels, + redis_cluster, + decode_responses=True, + ), ) def __setattr__(self, key, value): @@ -296,6 +312,9 @@ JWT_EXPIRES_IN = PersistentConfig( # OAuth config #################################### +ENABLE_OAUTH_PERSISTENT_CONFIG = ( + os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "True").lower() == "true" +) ENABLE_OAUTH_SIGNUP = PersistentConfig( "ENABLE_OAUTH_SIGNUP", @@ -463,6 +482,12 @@ OAUTH_PROVIDER_NAME = PersistentConfig( os.environ.get("OAUTH_PROVIDER_NAME", "SSO"), ) +OAUTH_SUB_CLAIM = PersistentConfig( + "OAUTH_SUB_CLAIM", + "oauth.oidc.sub_claim", + os.environ.get("OAUTH_SUB_CLAIM", None), +) + OAUTH_USERNAME_CLAIM = PersistentConfig( "OAUTH_USERNAME_CLAIM", "oauth.oidc.username_claim", @@ -680,6 +705,23 @@ def load_oauth_providers(): "register": oidc_oauth_register, } + configured_providers = [] + if GOOGLE_CLIENT_ID.value: + configured_providers.append("Google") + if MICROSOFT_CLIENT_ID.value: + configured_providers.append("Microsoft") + if GITHUB_CLIENT_ID.value: + configured_providers.append("GitHub") + + if configured_providers and not OPENID_PROVIDER_URL.value: + provider_list = ", ".join(configured_providers) + log.warning( + f"⚠️ OAuth providers configured ({provider_list}) but OPENID_PROVIDER_URL not set - logout will not work!" + ) + log.warning( + f"Set OPENID_PROVIDER_URL to your OAuth provider's OpenID Connect discovery endpoint to fix logout functionality." + ) + load_oauth_providers() @@ -1143,10 +1185,18 @@ USER_PERMISSIONS_CHAT_CONTROLS = ( os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true" ) +USER_PERMISSIONS_CHAT_VALVES = ( + os.environ.get("USER_PERMISSIONS_CHAT_VALVES", "True").lower() == "true" +) + USER_PERMISSIONS_CHAT_SYSTEM_PROMPT = ( os.environ.get("USER_PERMISSIONS_CHAT_SYSTEM_PROMPT", "True").lower() == "true" ) +USER_PERMISSIONS_CHAT_PARAMS = ( + os.environ.get("USER_PERMISSIONS_CHAT_PARAMS", "True").lower() == "true" +) + USER_PERMISSIONS_CHAT_FILE_UPLOAD = ( os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true" ) @@ -1232,7 +1282,9 @@ DEFAULT_USER_PERMISSIONS = { }, "chat": { "controls": USER_PERMISSIONS_CHAT_CONTROLS, + "valves": USER_PERMISSIONS_CHAT_VALVES, "system_prompt": USER_PERMISSIONS_CHAT_SYSTEM_PROMPT, + "params": USER_PERMISSIONS_CHAT_PARAMS, "file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD, "delete": USER_PERMISSIONS_CHAT_DELETE, "edit": USER_PERMISSIONS_CHAT_EDIT, @@ -1299,6 +1351,10 @@ WEBHOOK_URL = PersistentConfig( ENABLE_ADMIN_EXPORT = os.environ.get("ENABLE_ADMIN_EXPORT", "True").lower() == "true" +ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS = ( + os.environ.get("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS", "True").lower() == "true" +) + ENABLE_ADMIN_CHAT_ACCESS = ( os.environ.get("ENABLE_ADMIN_CHAT_ACCESS", "True").lower() == "true" ) @@ -1337,10 +1393,11 @@ if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str): def validate_cors_origin(origin): parsed_url = urlparse(origin) - # Check if the scheme is either http or https - if parsed_url.scheme not in ["http", "https"]: + # Check if the scheme is either http or https, or a custom scheme + schemes = ["http", "https"] + CORS_ALLOW_CUSTOM_SCHEME + if parsed_url.scheme not in schemes: raise ValueError( - f"Invalid scheme in CORS_ALLOW_ORIGIN: '{origin}'. Only 'http' and 'https' are allowed." + f"Invalid scheme in CORS_ALLOW_ORIGIN: '{origin}'. Only 'http' and 'https' and CORS_ALLOW_CUSTOM_SCHEME are allowed." ) # Ensure that the netloc (domain + port) is present, indicating it's a valid URL @@ -1355,6 +1412,11 @@ def validate_cors_origin(origin): # in your .env file depending on your frontend port, 5173 in this case. CORS_ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "*").split(";") +# Allows custom URL schemes (e.g., app://) to be used as origins for CORS. +# Useful for local development or desktop clients with schemes like app:// or other custom protocols. +# Provide a semicolon-separated list of allowed schemes in the environment variable CORS_ALLOW_CUSTOM_SCHEMES. +CORS_ALLOW_CUSTOM_SCHEME = os.environ.get("CORS_ALLOW_CUSTOM_SCHEME", "").split(";") + if CORS_ALLOW_ORIGIN == ["*"]: log.warning( "\n\nWARNING: CORS_ALLOW_ORIGIN IS SET TO '*' - NOT RECOMMENDED FOR PRODUCTION DEPLOYMENTS.\n" @@ -1862,6 +1924,8 @@ QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None) QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true" QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "false").lower() == "true" QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334")) +QDRANT_TIMEOUT = int(os.environ.get("QDRANT_TIMEOUT", "5")) +QDRANT_HNSW_M = int(os.environ.get("QDRANT_HNSW_M", "16")) ENABLE_QDRANT_MULTITENANCY_MODE = ( os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "true").lower() == "true" ) @@ -1951,6 +2015,37 @@ PINECONE_DIMENSION = int(os.getenv("PINECONE_DIMENSION", 1536)) # or 3072, 1024 PINECONE_METRIC = os.getenv("PINECONE_METRIC", "cosine") PINECONE_CLOUD = os.getenv("PINECONE_CLOUD", "aws") # or "gcp" or "azure" +# ORACLE23AI (Oracle23ai Vector Search) + +ORACLE_DB_USE_WALLET = os.environ.get("ORACLE_DB_USE_WALLET", "false").lower() == "true" +ORACLE_DB_USER = os.environ.get("ORACLE_DB_USER", None) # +ORACLE_DB_PASSWORD = os.environ.get("ORACLE_DB_PASSWORD", None) # +ORACLE_DB_DSN = os.environ.get("ORACLE_DB_DSN", None) # +ORACLE_WALLET_DIR = os.environ.get("ORACLE_WALLET_DIR", None) +ORACLE_WALLET_PASSWORD = os.environ.get("ORACLE_WALLET_PASSWORD", None) +ORACLE_VECTOR_LENGTH = os.environ.get("ORACLE_VECTOR_LENGTH", 768) + +ORACLE_DB_POOL_MIN = int(os.environ.get("ORACLE_DB_POOL_MIN", 2)) +ORACLE_DB_POOL_MAX = int(os.environ.get("ORACLE_DB_POOL_MAX", 10)) +ORACLE_DB_POOL_INCREMENT = int(os.environ.get("ORACLE_DB_POOL_INCREMENT", 1)) + + +if VECTOR_DB == "oracle23ai": + if not ORACLE_DB_USER or not ORACLE_DB_PASSWORD or not ORACLE_DB_DSN: + raise ValueError( + "Oracle23ai requires setting ORACLE_DB_USER, ORACLE_DB_PASSWORD, and ORACLE_DB_DSN." + ) + if ORACLE_DB_USE_WALLET and (not ORACLE_WALLET_DIR or not ORACLE_WALLET_PASSWORD): + raise ValueError( + "Oracle23ai requires setting ORACLE_WALLET_DIR and ORACLE_WALLET_PASSWORD when using wallet authentication." + ) + +log.info(f"VECTOR_DB: {VECTOR_DB}") + +# S3 Vector +S3_VECTOR_BUCKET_NAME = os.environ.get("S3_VECTOR_BUCKET_NAME", None) +S3_VECTOR_REGION = os.environ.get("S3_VECTOR_REGION", None) + #################################### # Information Retrieval (RAG) #################################### @@ -2012,10 +2107,16 @@ DATALAB_MARKER_API_KEY = PersistentConfig( os.environ.get("DATALAB_MARKER_API_KEY", ""), ) -DATALAB_MARKER_LANGS = PersistentConfig( - "DATALAB_MARKER_LANGS", - "rag.datalab_marker_langs", - os.environ.get("DATALAB_MARKER_LANGS", ""), +DATALAB_MARKER_API_BASE_URL = PersistentConfig( + "DATALAB_MARKER_API_BASE_URL", + "rag.datalab_marker_api_base_url", + os.environ.get("DATALAB_MARKER_API_BASE_URL", ""), +) + +DATALAB_MARKER_ADDITIONAL_CONFIG = PersistentConfig( + "DATALAB_MARKER_ADDITIONAL_CONFIG", + "rag.datalab_marker_additional_config", + os.environ.get("DATALAB_MARKER_ADDITIONAL_CONFIG", ""), ) DATALAB_MARKER_USE_LLM = PersistentConfig( @@ -2055,6 +2156,12 @@ DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = PersistentConfig( == "true", ) +DATALAB_MARKER_FORMAT_LINES = PersistentConfig( + "DATALAB_MARKER_FORMAT_LINES", + "rag.datalab_marker_format_lines", + os.environ.get("DATALAB_MARKER_FORMAT_LINES", "false").lower() == "true", +) + DATALAB_MARKER_OUTPUT_FORMAT = PersistentConfig( "DATALAB_MARKER_OUTPUT_FORMAT", "rag.datalab_marker_output_format", diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 01c6f0468b..f6a5300943 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -288,6 +288,9 @@ DB_VARS = { if all(DB_VARS.values()): DATABASE_URL = f"{DB_VARS['db_type']}://{DB_VARS['db_cred']}@{DB_VARS['db_host']}:{DB_VARS['db_port']}/{DB_VARS['db_name']}" +elif DATABASE_TYPE == "sqlite+sqlcipher" and not os.environ.get("DATABASE_URL"): + # Handle SQLCipher with local file when DATABASE_URL wasn't explicitly set + DATABASE_URL = f"sqlite+sqlcipher:///{DATA_DIR}/webui.db" # Replace the postgres:// with postgresql:// if "postgres://" in DATABASE_URL: @@ -346,7 +349,10 @@ ENABLE_REALTIME_CHAT_SAVE = ( #################################### REDIS_URL = os.environ.get("REDIS_URL", "") +REDIS_CLUSTER = os.environ.get("REDIS_CLUSTER", "False").lower() == "true" + REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui") + REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "") REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379") @@ -378,6 +384,10 @@ except ValueError: #################################### WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true" +ENABLE_SIGNUP_PASSWORD_CONFIRMATION = ( + os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true" +) + WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None ) @@ -432,6 +442,13 @@ ENABLE_COMPRESSION_MIDDLEWARE = ( ) +#################################### +# SCIM Configuration +#################################### + +SCIM_ENABLED = os.environ.get("SCIM_ENABLED", "False").lower() == "true" +SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "") + #################################### # LICENSE_KEY #################################### @@ -473,6 +490,25 @@ else: MODELS_CACHE_TTL = 1 +#################################### +# CHAT +#################################### + +CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = os.environ.get( + "CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE", "1" +) + +if CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE == "": + CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1 +else: + try: + CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = int( + CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE + ) + except Exception: + CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1 + + #################################### # WEBSOCKET SUPPORT #################################### @@ -485,6 +521,9 @@ ENABLE_WEBSOCKET_SUPPORT = ( WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "") WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL) +WEBSOCKET_REDIS_CLUSTER = ( + os.environ.get("WEBSOCKET_REDIS_CLUSTER", str(REDIS_CLUSTER)).lower() == "true" +) websocket_redis_lock_timeout = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", "60") @@ -494,9 +533,9 @@ except ValueError: 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", "") if AIOHTTP_CLIENT_TIMEOUT == "": @@ -639,12 +678,26 @@ AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS] ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true" ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() == "true" +ENABLE_OTEL_LOGS = os.environ.get("ENABLE_OTEL_LOGS", "False").lower() == "true" + OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get( "OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317" ) +OTEL_METRICS_EXPORTER_OTLP_ENDPOINT = os.environ.get( + "OTEL_METRICS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT +) +OTEL_LOGS_EXPORTER_OTLP_ENDPOINT = os.environ.get( + "OTEL_LOGS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT +) OTEL_EXPORTER_OTLP_INSECURE = ( os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true" ) +OTEL_METRICS_EXPORTER_OTLP_INSECURE = ( + os.environ.get("OTEL_METRICS_EXPORTER_OTLP_INSECURE", "False").lower() == "true" +) +OTEL_LOGS_EXPORTER_OTLP_INSECURE = ( + os.environ.get("OTEL_LOGS_EXPORTER_OTLP_INSECURE", "False").lower() == "true" +) OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui") OTEL_RESOURCE_ATTRIBUTES = os.environ.get( "OTEL_RESOURCE_ATTRIBUTES", "" @@ -655,11 +708,30 @@ OTEL_TRACES_SAMPLER = os.environ.get( OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "") OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "") +OTEL_METRICS_BASIC_AUTH_USERNAME = os.environ.get( + "OTEL_METRICS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME +) +OTEL_METRICS_BASIC_AUTH_PASSWORD = os.environ.get( + "OTEL_METRICS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD +) +OTEL_LOGS_BASIC_AUTH_USERNAME = os.environ.get( + "OTEL_LOGS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME +) +OTEL_LOGS_BASIC_AUTH_PASSWORD = os.environ.get( + "OTEL_LOGS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD +) OTEL_OTLP_SPAN_EXPORTER = os.environ.get( "OTEL_OTLP_SPAN_EXPORTER", "grpc" ).lower() # grpc or http +OTEL_METRICS_OTLP_SPAN_EXPORTER = os.environ.get( + "OTEL_METRICS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER +).lower() # grpc or http + +OTEL_LOGS_OTLP_SPAN_EXPORTER = os.environ.get( + "OTEL_LOGS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER +).lower() # grpc or http #################################### # TOOLS/FUNCTIONS PIP OPTIONS diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index e1ffc1eb27..d7a200ff20 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -1,3 +1,4 @@ +import os import json import logging from contextlib import contextmanager @@ -79,7 +80,37 @@ handle_peewee_migration(DATABASE_URL) SQLALCHEMY_DATABASE_URL = DATABASE_URL -if "sqlite" in SQLALCHEMY_DATABASE_URL: + +# Handle SQLCipher URLs +if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"): + database_password = os.environ.get("DATABASE_PASSWORD") + if not database_password or database_password.strip() == "": + raise ValueError( + "DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs" + ) + + # Extract database path from SQLCipher URL + db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "") + if db_path.startswith("/"): + db_path = db_path[1:] # Remove leading slash for relative paths + + # Create a custom creator function that uses sqlcipher3 + def create_sqlcipher_connection(): + import sqlcipher3 + + conn = sqlcipher3.connect(db_path, check_same_thread=False) + conn.execute(f"PRAGMA key = '{database_password}'") + return conn + + engine = create_engine( + "sqlite://", # Dummy URL since we're using creator + creator=create_sqlcipher_connection, + echo=False, + ) + + log.info("Connected to encrypted SQLite database using SQLCipher") + +elif "sqlite" in SQLALCHEMY_DATABASE_URL: engine = create_engine( SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} ) diff --git a/backend/open_webui/internal/wrappers.py b/backend/open_webui/internal/wrappers.py index 5cf3529302..554a5effdd 100644 --- a/backend/open_webui/internal/wrappers.py +++ b/backend/open_webui/internal/wrappers.py @@ -1,4 +1,5 @@ import logging +import os from contextvars import ContextVar from open_webui.env import SRC_LOG_LEVELS @@ -43,24 +44,47 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase): def register_connection(db_url): - db = connect(db_url, unquote_user=True, unquote_password=True) - if isinstance(db, PostgresqlDatabase): - # Enable autoconnect for SQLite databases, managed by Peewee + # Check if using SQLCipher protocol + if db_url.startswith("sqlite+sqlcipher://"): + database_password = os.environ.get("DATABASE_PASSWORD") + if not database_password or database_password.strip() == "": + raise ValueError( + "DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs" + ) + from playhouse.sqlcipher_ext import SqlCipherDatabase + + # Parse the database path from SQLCipher URL + # Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite + db_path = db_url.replace("sqlite+sqlcipher://", "") + if db_path.startswith("/"): + db_path = db_path[1:] # Remove leading slash for relative paths + + # Use Peewee's native SqlCipherDatabase with encryption + db = SqlCipherDatabase(db_path, passphrase=database_password) db.autoconnect = True db.reuse_if_open = True - log.info("Connected to PostgreSQL database") + log.info("Connected to encrypted SQLite database using SQLCipher") - # Get the connection details - connection = parse(db_url, unquote_user=True, unquote_password=True) - - # Use our custom database class that supports reconnection - db = ReconnectingPostgresqlDatabase(**connection) - db.connect(reuse_if_open=True) - elif isinstance(db, SqliteDatabase): - # Enable autoconnect for SQLite databases, managed by Peewee - db.autoconnect = True - db.reuse_if_open = True - log.info("Connected to SQLite database") else: - raise ValueError("Unsupported database connection") + # Standard database connection (existing logic) + db = connect(db_url, unquote_user=True, unquote_password=True) + if isinstance(db, PostgresqlDatabase): + # Enable autoconnect for SQLite databases, managed by Peewee + db.autoconnect = True + db.reuse_if_open = True + log.info("Connected to PostgreSQL database") + + # Get the connection details + connection = parse(db_url, unquote_user=True, unquote_password=True) + + # Use our custom database class that supports reconnection + db = ReconnectingPostgresqlDatabase(**connection) + db.connect(reuse_if_open=True) + elif isinstance(db, SqliteDatabase): + # Enable autoconnect for SQLite databases, managed by Peewee + db.autoconnect = True + db.reuse_if_open = True + log.info("Connected to SQLite database") + else: + raise ValueError("Unsupported database connection") return db diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index fe1fd6ded8..bf85978874 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -85,6 +85,7 @@ from open_webui.routers import ( tools, users, utils, + scim, ) from open_webui.routers.retrieval import ( @@ -226,12 +227,14 @@ from open_webui.config import ( CHUNK_SIZE, CONTENT_EXTRACTION_ENGINE, DATALAB_MARKER_API_KEY, - DATALAB_MARKER_LANGS, + DATALAB_MARKER_API_BASE_URL, + DATALAB_MARKER_ADDITIONAL_CONFIG, DATALAB_MARKER_SKIP_CACHE, DATALAB_MARKER_FORCE_OCR, DATALAB_MARKER_PAGINATE, DATALAB_MARKER_STRIP_EXISTING_OCR, DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + DATALAB_MARKER_FORMAT_LINES, DATALAB_MARKER_OUTPUT_FORMAT, DATALAB_MARKER_USE_LLM, EXTERNAL_DOCUMENT_LOADER_URL, @@ -399,6 +402,7 @@ from open_webui.env import ( AUDIT_LOG_LEVEL, CHANGELOG, REDIS_URL, + REDIS_CLUSTER, REDIS_KEY_PREFIX, REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT, @@ -412,9 +416,13 @@ from open_webui.env import ( WEBUI_SECRET_KEY, WEBUI_SESSION_COOKIE_SAME_SITE, WEBUI_SESSION_COOKIE_SECURE, + ENABLE_SIGNUP_PASSWORD_CONFIRMATION, WEBUI_AUTH_TRUSTED_EMAIL_HEADER, WEBUI_AUTH_TRUSTED_NAME_HEADER, WEBUI_AUTH_SIGNOUT_REDIRECT_URL, + # SCIM + SCIM_ENABLED, + SCIM_TOKEN, ENABLE_COMPRESSION_MIDDLEWARE, ENABLE_WEBSOCKET_SUPPORT, BYPASS_MODEL_ACCESS_CONTROL, @@ -462,6 +470,9 @@ from open_webui.tasks import ( from open_webui.utils.redis import get_sentinels_from_env +from open_webui.constants import ERROR_MESSAGES + + if SAFE_MODE: print("SAFE MODE ENABLED") Functions.deactivate_all_functions() @@ -524,6 +535,7 @@ async def lifespan(app: FastAPI): redis_sentinels=get_sentinels_from_env( REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT ), + redis_cluster=REDIS_CLUSTER, async_mode=True, ) @@ -579,6 +591,7 @@ app.state.instance_id = None app.state.config = AppConfig( redis_url=REDIS_URL, redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), + redis_cluster=REDIS_CLUSTER, redis_key_prefix=REDIS_KEY_PREFIX, ) app.state.redis = None @@ -642,6 +655,15 @@ app.state.TOOL_SERVERS = [] app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS +######################################## +# +# SCIM +# +######################################## + +app.state.SCIM_ENABLED = SCIM_ENABLED +app.state.SCIM_TOKEN = SCIM_TOKEN + ######################################## # # MODELS @@ -767,7 +789,8 @@ app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ENABLE_WEB_LOADER_SSL_VERI app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE app.state.config.DATALAB_MARKER_API_KEY = DATALAB_MARKER_API_KEY -app.state.config.DATALAB_MARKER_LANGS = DATALAB_MARKER_LANGS +app.state.config.DATALAB_MARKER_API_BASE_URL = DATALAB_MARKER_API_BASE_URL +app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG = DATALAB_MARKER_ADDITIONAL_CONFIG app.state.config.DATALAB_MARKER_SKIP_CACHE = DATALAB_MARKER_SKIP_CACHE app.state.config.DATALAB_MARKER_FORCE_OCR = DATALAB_MARKER_FORCE_OCR app.state.config.DATALAB_MARKER_PAGINATE = DATALAB_MARKER_PAGINATE @@ -775,6 +798,7 @@ app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = DATALAB_MARKER_STRIP_EXISTI app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = ( DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION ) +app.state.config.DATALAB_MARKER_FORMAT_LINES = DATALAB_MARKER_FORMAT_LINES app.state.config.DATALAB_MARKER_USE_LLM = DATALAB_MARKER_USE_LLM app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = DATALAB_MARKER_OUTPUT_FORMAT app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL @@ -1211,6 +1235,10 @@ app.include_router( ) app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) +# SCIM 2.0 API for identity management +if SCIM_ENABLED: + app.include_router(scim.router, prefix="/api/v1/scim/v2", tags=["scim"]) + try: audit_level = AuditLevel(AUDIT_LOG_LEVEL) @@ -1296,7 +1324,7 @@ async def get_models( models = get_filtered_models(models, user) log.debug( - f"/api/models returned filtered models accessible to the user: {json.dumps([model['id'] for model in models])}" + f"/api/models returned filtered models accessible to the user: {json.dumps([model.get('id') for model in models])}" ) return {"data": models} @@ -1373,6 +1401,19 @@ async def chat_completion( request.state.direct = True request.state.model = model + model_info_params = ( + model_info.params.model_dump() if model_info and model_info.params else {} + ) + + # Chat Params + stream_delta_chunk_size = form_data.get("params", {}).get( + "stream_delta_chunk_size" + ) + + # Model Params + if model_info_params.get("stream_delta_chunk_size"): + stream_delta_chunk_size = model_info_params.get("stream_delta_chunk_size") + metadata = { "user_id": user.id, "chat_id": form_data.pop("chat_id", None), @@ -1386,25 +1427,33 @@ async def chat_completion( "variables": form_data.get("variables", {}), "model": model, "direct": model_item.get("direct", False), - **( - {"function_calling": "native"} - if form_data.get("params", {}).get("function_calling") == "native" - or ( - model_info - and model_info.params.model_dump().get("function_calling") - == "native" - ) - else {} - ), + "params": { + "stream_delta_chunk_size": stream_delta_chunk_size, + "function_calling": ( + "native" + if ( + form_data.get("params", {}).get("function_calling") == "native" + or model_info_params.get("function_calling") == "native" + ) + else "default" + ), + }, } + if metadata.get("chat_id") and (user and user.role != "admin"): + chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id) + if chat is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.DEFAULT(), + ) + request.state.metadata = metadata form_data["metadata"] = metadata form_data, metadata, events = await process_chat_payload( request, form_data, user, metadata, model ) - except Exception as e: log.debug(f"Error processing chat payload: {e}") if metadata.get("chat_id") and metadata.get("message_id"): @@ -1424,6 +1473,14 @@ async def chat_completion( try: response = await chat_completion_handler(request, form_data, user) + if metadata.get("chat_id") and metadata.get("message_id"): + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "model": model_id, + }, + ) return await process_chat_response( request, response, form_data, user, metadata, model, events, tasks @@ -1563,6 +1620,7 @@ async def get_app_config(request: Request): "features": { "auth": WEBUI_AUTH, "auth_trusted_header": bool(app.state.AUTH_TRUSTED_EMAIL_HEADER), + "enable_signup_password_confirmation": ENABLE_SIGNUP_PASSWORD_CONFIRMATION, "enable_ldap": app.state.config.ENABLE_LDAP, "enable_api_key": app.state.config.ENABLE_API_KEY, "enable_signup": app.state.config.ENABLE_SIGNUP, @@ -1641,14 +1699,17 @@ async def get_app_config(request: Request): else {} ), } - if user is not None + if user is not None and (user.role in ["admin", "user"]) else { **( { "metadata": { "login_footer": app.state.LICENSE_METADATA.get( "login_footer", "" - ) + ), + "auth_logo_position": app.state.LICENSE_METADATA.get( + "auth_logo_position", "" + ), } } if app.state.LICENSE_METADATA @@ -1765,11 +1826,10 @@ async def get_manifest_json(): return { "name": app.state.WEBUI_NAME, "short_name": app.state.WEBUI_NAME, - "description": "Open WebUI is an open, extensible, user-friendly interface for AI that adapts to your workflow.", + "description": f"{app.state.WEBUI_NAME} is an open, extensible, user-friendly interface for AI that adapts to your workflow.", "start_url": "/", "display": "standalone", "background_color": "#343541", - "orientation": "any", "icons": [ { "src": "/static/logo.png", diff --git a/backend/open_webui/migrations/env.py b/backend/open_webui/migrations/env.py index 1288816471..7db9251282 100644 --- a/backend/open_webui/migrations/env.py +++ b/backend/open_webui/migrations/env.py @@ -2,8 +2,8 @@ from logging.config import fileConfig from alembic import context from open_webui.models.auths import Auth -from open_webui.env import DATABASE_URL -from sqlalchemy import engine_from_config, pool +from open_webui.env import DATABASE_URL, DATABASE_PASSWORD +from sqlalchemy import engine_from_config, pool, create_engine # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -62,11 +62,38 @@ def run_migrations_online() -> None: and associate a connection with the context. """ - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) + # Handle SQLCipher URLs + if DB_URL and DB_URL.startswith("sqlite+sqlcipher://"): + if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == "": + raise ValueError( + "DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs" + ) + + # Extract database path from SQLCipher URL + db_path = DB_URL.replace("sqlite+sqlcipher://", "") + if db_path.startswith("/"): + db_path = db_path[1:] # Remove leading slash for relative paths + + # Create a custom creator function that uses sqlcipher3 + def create_sqlcipher_connection(): + import sqlcipher3 + + conn = sqlcipher3.connect(db_path, check_same_thread=False) + conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'") + return conn + + connectable = create_engine( + "sqlite://", # Dummy URL since we're using creator + creator=create_sqlcipher_connection, + echo=False, + ) + else: + # Standard database connection (existing logic) + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) with connectable.connect() as connection: context.configure(connection=connection, target_metadata=target_metadata) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index b9de2e5a4e..a70af898d4 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -6,6 +6,7 @@ from typing import Optional from open_webui.internal.db import Base, get_db from open_webui.models.tags import TagModel, Tag, Tags +from open_webui.models.folders import Folders from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict @@ -296,6 +297,9 @@ class ChatTable: "user_id": f"shared-{chat_id}", "title": chat.title, "chat": chat.chat, + "meta": chat.meta, + "pinned": chat.pinned, + "folder_id": chat.folder_id, "created_at": chat.created_at, "updated_at": int(time.time()), } @@ -327,7 +331,9 @@ class ChatTable: shared_chat.title = chat.title shared_chat.chat = chat.chat - + shared_chat.meta = chat.meta + shared_chat.pinned = chat.pinned + shared_chat.folder_id = chat.folder_id shared_chat.updated_at = int(time.time()) db.commit() db.refresh(shared_chat) @@ -612,8 +618,45 @@ class ChatTable: if word.startswith("tag:") ] + # Extract folder names - handle spaces and case insensitivity + folders = Folders.search_folders_by_names( + user_id, + [ + word.replace("folder:", "") + for word in search_text_words + if word.startswith("folder:") + ], + ) + folder_ids = [folder.id for folder in folders] + + is_pinned = None + if "pinned:true" in search_text_words: + is_pinned = True + elif "pinned:false" in search_text_words: + is_pinned = False + + is_archived = None + if "archived:true" in search_text_words: + is_archived = True + elif "archived:false" in search_text_words: + is_archived = False + + is_shared = None + if "shared:true" in search_text_words: + is_shared = True + elif "shared:false" in search_text_words: + is_shared = False + search_text_words = [ - word for word in search_text_words if not word.startswith("tag:") + word + for word in search_text_words + if ( + not word.startswith("tag:") + and not word.startswith("folder:") + and not word.startswith("pinned:") + and not word.startswith("archived:") + and not word.startswith("shared:") + ) ] search_text = " ".join(search_text_words) @@ -621,9 +664,23 @@ class ChatTable: with get_db() as db: query = db.query(Chat).filter(Chat.user_id == user_id) - if not include_archived: + if is_archived is not None: + query = query.filter(Chat.archived == is_archived) + elif not include_archived: query = query.filter(Chat.archived == False) + if is_pinned is not None: + query = query.filter(Chat.pinned == is_pinned) + + if is_shared is not None: + if is_shared: + query = query.filter(Chat.share_id.isnot(None)) + else: + query = query.filter(Chat.share_id.is_(None)) + + if folder_ids: + query = query.filter(Chat.folder_id.in_(folder_ids)) + query = query.order_by(Chat.updated_at.desc()) # Check if the database dialect is either 'sqlite' or 'postgresql' diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index a42748c5b1..15deecbf42 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -2,14 +2,14 @@ import logging import time import uuid from typing import Optional +import re + + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func from open_webui.internal.db import Base, get_db -from open_webui.models.chats import Chats - from open_webui.env import SRC_LOG_LEVELS -from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Column, Text, JSON, Boolean -from open_webui.utils.access_control import get_permissions log = logging.getLogger(__name__) @@ -106,7 +106,7 @@ class FolderTable: def get_children_folders_by_id_and_user_id( self, id: str, user_id: str - ) -> Optional[FolderModel]: + ) -> Optional[list[FolderModel]]: try: with get_db() as db: folders = [] @@ -251,18 +251,15 @@ class FolderTable: log.error(f"update_folder: {e}") return - def delete_folder_by_id_and_user_id( - self, id: str, user_id: str, delete_chats=True - ) -> bool: + def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> list[str]: try: + folder_ids = [] with get_db() as db: folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() if not folder: - return False + return folder_ids - if delete_chats: - # Delete all chats in the folder - Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id) + folder_ids.append(folder.id) # Delete all children folders def delete_children(folder): @@ -270,12 +267,9 @@ class FolderTable: folder.id, user_id ) for folder_child in folder_children: - if delete_chats: - Chats.delete_chats_by_user_id_and_folder_id( - user_id, folder_child.id - ) delete_children(folder_child) + folder_ids.append(folder_child.id) folder = db.query(Folder).filter_by(id=folder_child.id).first() db.delete(folder) @@ -284,10 +278,62 @@ class FolderTable: delete_children(folder) db.delete(folder) db.commit() - return True + return folder_ids except Exception as e: log.error(f"delete_folder: {e}") - return False + return [] + + def normalize_folder_name(self, name: str) -> str: + # Replace _ and space with a single space, lower case, collapse multiple spaces + name = re.sub(r"[\s_]+", " ", name) + return name.strip().lower() + + def search_folders_by_names( + self, user_id: str, queries: list[str] + ) -> list[FolderModel]: + """ + Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive. + """ + normalized_queries = [self.normalize_folder_name(q) for q in queries] + if not normalized_queries: + return [] + + results = {} + with get_db() as db: + folders = db.query(Folder).filter_by(user_id=user_id).all() + for folder in folders: + if self.normalize_folder_name(folder.name) in normalized_queries: + results[folder.id] = FolderModel.model_validate(folder) + + # get children folders + children = self.get_children_folders_by_id_and_user_id( + folder.id, user_id + ) + for child in children: + results[child.id] = child + + # Return the results as a list + if not results: + return [] + else: + results = list(results.values()) + return results + + def search_folders_by_name_contains( + self, user_id: str, query: str + ) -> list[FolderModel]: + """ + Partial match: normalized name contains (as substring) the normalized query. + """ + normalized_query = self.normalize_folder_name(query) + results = [] + with get_db() as db: + folders = db.query(Folder).filter_by(user_id=user_id).all() + for folder in folders: + norm_name = self.normalize_folder_name(folder.name) + if normalized_query in norm_name: + results.append(FolderModel.model_validate(folder)) + return results Folders = FolderTable() diff --git a/backend/open_webui/models/memories.py b/backend/open_webui/models/memories.py index 8b10a77cf9..253371c680 100644 --- a/backend/open_webui/models/memories.py +++ b/backend/open_webui/models/memories.py @@ -71,9 +71,13 @@ class MemoriesTable: ) -> Optional[MemoryModel]: with get_db() as db: try: - db.query(Memory).filter_by(id=id, user_id=user_id).update( - {"content": content, "updated_at": int(time.time())} - ) + memory = db.get(Memory, id) + if not memory or memory.user_id != user_id: + return None + + memory.content = content + memory.updated_at = int(time.time()) + db.commit() return self.get_memory_by_id(id) except Exception: @@ -127,7 +131,12 @@ class MemoriesTable: def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool: with get_db() as db: try: - db.query(Memory).filter_by(id=id, user_id=user_id).delete() + memory = db.get(Memory, id) + if not memory or memory.user_id != user_id: + return None + + # Delete the memory + db.delete(memory) db.commit() return True diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 7df8d8656b..1a29b86eae 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -269,5 +269,49 @@ class ModelsTable: except Exception: return False + def sync_models(self, user_id: str, models: list[ModelModel]) -> list[ModelModel]: + try: + with get_db() as db: + # Get existing models + existing_models = db.query(Model).all() + existing_ids = {model.id for model in existing_models} + + # Prepare a set of new model IDs + new_model_ids = {model.id for model in models} + + # Update or insert models + for model in models: + if model.id in existing_ids: + db.query(Model).filter_by(id=model.id).update( + { + **model.model_dump(), + "user_id": user_id, + "updated_at": int(time.time()), + } + ) + else: + new_model = Model( + **{ + **model.model_dump(), + "user_id": user_id, + "updated_at": int(time.time()), + } + ) + db.add(new_model) + + # Remove models that are no longer present + for model in existing_models: + if model.id not in new_model_ids: + db.delete(model) + + db.commit() + + return [ + ModelModel.model_validate(model) for model in db.query(Model).all() + ] + except Exception as e: + log.exception(f"Error syncing models for user {user_id}: {e}") + return [] + Models = ModelsTable() diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index f7ea905a65..60b6ad0c10 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -258,6 +258,10 @@ class UsersTable: with get_db() as db: return db.query(User).count() + def has_users(self) -> bool: + with get_db() as db: + return db.query(db.query(User).exists()).scalar() + def get_first_user(self) -> UserModel: try: with get_db() as db: diff --git a/backend/open_webui/retrieval/loaders/datalab_marker.py b/backend/open_webui/retrieval/loaders/datalab_marker.py index 104c2830df..cc6c7ce79d 100644 --- a/backend/open_webui/retrieval/loaders/datalab_marker.py +++ b/backend/open_webui/retrieval/loaders/datalab_marker.py @@ -15,24 +15,28 @@ class DatalabMarkerLoader: self, file_path: str, api_key: str, - langs: Optional[str] = None, + api_base_url: str, + additional_config: Optional[str] = None, use_llm: bool = False, skip_cache: bool = False, force_ocr: bool = False, paginate: bool = False, strip_existing_ocr: bool = False, disable_image_extraction: bool = False, + format_lines: bool = False, output_format: str = None, ): self.file_path = file_path self.api_key = api_key - self.langs = langs + self.api_base_url = api_base_url + self.additional_config = additional_config self.use_llm = use_llm self.skip_cache = skip_cache self.force_ocr = force_ocr self.paginate = paginate self.strip_existing_ocr = strip_existing_ocr self.disable_image_extraction = disable_image_extraction + self.format_lines = format_lines self.output_format = output_format def _get_mime_type(self, filename: str) -> str: @@ -60,7 +64,7 @@ class DatalabMarkerLoader: return mime_map.get(ext, "application/octet-stream") def check_marker_request_status(self, request_id: str) -> dict: - url = f"https://www.datalab.to/api/v1/marker/{request_id}" + url = f"{self.api_base_url}/marker/{request_id}" headers = {"X-Api-Key": self.api_key} try: response = requests.get(url, headers=headers) @@ -81,22 +85,24 @@ class DatalabMarkerLoader: ) def load(self) -> List[Document]: - url = "https://www.datalab.to/api/v1/marker" filename = os.path.basename(self.file_path) mime_type = self._get_mime_type(filename) headers = {"X-Api-Key": self.api_key} form_data = { - "langs": self.langs, "use_llm": str(self.use_llm).lower(), "skip_cache": str(self.skip_cache).lower(), "force_ocr": str(self.force_ocr).lower(), "paginate": str(self.paginate).lower(), "strip_existing_ocr": str(self.strip_existing_ocr).lower(), "disable_image_extraction": str(self.disable_image_extraction).lower(), + "format_lines": str(self.format_lines).lower(), "output_format": self.output_format, } + if self.additional_config and self.additional_config.strip(): + form_data["additional_config"] = self.additional_config + log.info( f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}" ) @@ -105,7 +111,10 @@ class DatalabMarkerLoader: with open(self.file_path, "rb") as f: files = {"file": (filename, f, mime_type)} response = requests.post( - url, data=form_data, files=files, headers=headers + f"{self.api_base_url}/marker", + data=form_data, + files=files, + headers=headers, ) response.raise_for_status() result = response.json() @@ -133,74 +142,92 @@ class DatalabMarkerLoader: check_url = result.get("request_check_url") request_id = result.get("request_id") - if not check_url: - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, detail="No request_check_url returned." - ) - for _ in range(300): # Up to 10 minutes - time.sleep(2) - try: - poll_response = requests.get(check_url, headers=headers) - poll_response.raise_for_status() - poll_result = poll_response.json() - except (requests.HTTPError, ValueError) as e: - raw_body = poll_response.text - log.error(f"Polling error: {e}, response body: {raw_body}") - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}" - ) - - status_val = poll_result.get("status") - success_val = poll_result.get("success") - - if status_val == "complete": - summary = { - k: poll_result.get(k) - for k in ( - "status", - "output_format", - "success", - "error", - "page_count", - "total_cost", + # Check if this is a direct response (self-hosted) or polling response (DataLab) + if check_url: + # DataLab polling pattern + for _ in range(300): # Up to 10 minutes + time.sleep(2) + try: + poll_response = requests.get(check_url, headers=headers) + poll_response.raise_for_status() + poll_result = poll_response.json() + except (requests.HTTPError, ValueError) as e: + raw_body = poll_response.text + log.error(f"Polling error: {e}, response body: {raw_body}") + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}" ) - } - log.info( - f"Marker processing completed successfully: {json.dumps(summary, indent=2)}" - ) - break - if status_val == "failed" or success_val is False: - log.error( - f"Marker poll failed full response: {json.dumps(poll_result, indent=2)}" - ) - error_msg = ( - poll_result.get("error") - or "Marker returned failure without error message" + status_val = poll_result.get("status") + success_val = poll_result.get("success") + + if status_val == "complete": + summary = { + k: poll_result.get(k) + for k in ( + "status", + "output_format", + "success", + "error", + "page_count", + "total_cost", + ) + } + log.info( + f"Marker processing completed successfully: {json.dumps(summary, indent=2)}" + ) + break + + if status_val == "failed" or success_val is False: + log.error( + f"Marker poll failed full response: {json.dumps(poll_result, indent=2)}" + ) + error_msg = ( + poll_result.get("error") + or "Marker returned failure without error message" + ) + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Marker processing failed: {error_msg}", + ) + else: + raise HTTPException( + status.HTTP_504_GATEWAY_TIMEOUT, + detail="Marker processing timed out", ) + + if not poll_result.get("success", False): + error_msg = poll_result.get("error") or "Unknown processing error" raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Marker processing failed: {error_msg}", + detail=f"Final processing failed: {error_msg}", ) + + # DataLab format - content in format-specific fields + content_key = self.output_format.lower() + raw_content = poll_result.get(content_key) + final_result = poll_result else: - raise HTTPException( - status.HTTP_504_GATEWAY_TIMEOUT, detail="Marker processing timed out" - ) + # Self-hosted direct response - content in "output" field + if "output" in result: + log.info("Self-hosted Marker returned direct response without polling") + raw_content = result.get("output") + final_result = result + else: + available_fields = ( + list(result.keys()) + if isinstance(result, dict) + else "non-dict response" + ) + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + detail=f"Custom Marker endpoint returned success but no 'output' field found. Available fields: {available_fields}. Expected either 'request_check_url' for polling or 'output' field for direct response.", + ) - if not poll_result.get("success", False): - error_msg = poll_result.get("error") or "Unknown processing error" - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail=f"Final processing failed: {error_msg}", - ) - - content_key = self.output_format.lower() - raw_content = poll_result.get(content_key) - - if content_key == "json": + if self.output_format.lower() == "json": full_text = json.dumps(raw_content, indent=2) - elif content_key in {"markdown", "html"}: + elif self.output_format.lower() in {"markdown", "html"}: full_text = str(raw_content).strip() else: raise HTTPException( @@ -211,14 +238,14 @@ class DatalabMarkerLoader: if not full_text: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="Datalab Marker returned empty content", + detail="Marker returned empty content", ) marker_output_dir = os.path.join("/app/backend/data/uploads", "marker_output") os.makedirs(marker_output_dir, exist_ok=True) file_ext_map = {"markdown": "md", "json": "json", "html": "html"} - file_ext = file_ext_map.get(content_key, "txt") + file_ext = file_ext_map.get(self.output_format.lower(), "txt") output_filename = f"{os.path.splitext(filename)[0]}.{file_ext}" output_path = os.path.join(marker_output_dir, output_filename) @@ -231,13 +258,13 @@ class DatalabMarkerLoader: metadata = { "source": filename, - "output_format": poll_result.get("output_format", self.output_format), - "page_count": poll_result.get("page_count", 0), + "output_format": final_result.get("output_format", self.output_format), + "page_count": final_result.get("page_count", 0), "processed_with_llm": self.use_llm, "request_id": request_id or "", } - images = poll_result.get("images", {}) + images = final_result.get("images", {}) if images: metadata["image_count"] = len(images) metadata["images"] = json.dumps(list(images.keys())) diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index e57323e1eb..241cd7dbe8 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -181,7 +181,7 @@ class DoclingLoader: if lang.strip() ] - endpoint = f"{self.url}/v1alpha/convert/file" + endpoint = f"{self.url}/v1/convert/file" r = requests.post(endpoint, files=files, data=params) if r.ok: @@ -281,10 +281,15 @@ class Loader: "tiff", ] ): + api_base_url = self.kwargs.get("DATALAB_MARKER_API_BASE_URL", "") + if not api_base_url or api_base_url.strip() == "": + api_base_url = "https://www.datalab.to/api/v1" + loader = DatalabMarkerLoader( file_path=file_path, api_key=self.kwargs["DATALAB_MARKER_API_KEY"], - langs=self.kwargs.get("DATALAB_MARKER_LANGS"), + api_base_url=api_base_url, + additional_config=self.kwargs.get("DATALAB_MARKER_ADDITIONAL_CONFIG"), use_llm=self.kwargs.get("DATALAB_MARKER_USE_LLM", False), skip_cache=self.kwargs.get("DATALAB_MARKER_SKIP_CACHE", False), force_ocr=self.kwargs.get("DATALAB_MARKER_FORCE_OCR", False), @@ -295,6 +300,7 @@ class Loader: disable_image_extraction=self.kwargs.get( "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", False ), + format_lines=self.kwargs.get("DATALAB_MARKER_FORMAT_LINES", False), output_format=self.kwargs.get( "DATALAB_MARKER_OUTPUT_FORMAT", "markdown" ), diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 9158f8536e..539adda329 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -508,7 +508,11 @@ def get_sources_from_items( # Note Attached note = Notes.get_note_by_id(item.get("id")) - if user.role == "admin" or has_access(user.id, "read", note.access_control): + if note and ( + user.role == "admin" + or note.user_id == user.id + or has_access(user.id, "read", note.access_control) + ): # User has access to the note query_result = { "documents": [[note.data.get("content", {}).get("md", "")]], diff --git a/backend/open_webui/retrieval/vector/dbs/chroma.py b/backend/open_webui/retrieval/vector/dbs/chroma.py index f9adc9c95f..9675e141e7 100755 --- a/backend/open_webui/retrieval/vector/dbs/chroma.py +++ b/backend/open_webui/retrieval/vector/dbs/chroma.py @@ -11,6 +11,8 @@ from open_webui.retrieval.vector.main import ( SearchResult, GetResult, ) +from open_webui.retrieval.vector.utils import stringify_metadata + from open_webui.config import ( CHROMA_DATA_PATH, CHROMA_HTTP_HOST, @@ -144,7 +146,7 @@ class ChromaClient(VectorDBBase): ids = [item["id"] for item in items] documents = [item["text"] for item in items] embeddings = [item["vector"] for item in items] - metadatas = [item["metadata"] for item in items] + metadatas = [stringify_metadata(item["metadata"]) for item in items] for batch in create_batches( api=self.client, @@ -164,7 +166,7 @@ class ChromaClient(VectorDBBase): ids = [item["id"] for item in items] documents = [item["text"] for item in items] embeddings = [item["vector"] for item in items] - metadatas = [item["metadata"] for item in items] + metadatas = [stringify_metadata(item["metadata"]) for item in items] collection.upsert( ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas diff --git a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py index 18a915e381..727d831cff 100644 --- a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py +++ b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py @@ -2,6 +2,8 @@ from elasticsearch import Elasticsearch, BadRequestError from typing import Optional import ssl from elasticsearch.helpers import bulk, scan + +from open_webui.retrieval.vector.utils import stringify_metadata from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, @@ -243,7 +245,7 @@ class ElasticsearchClient(VectorDBBase): "collection": collection_name, "vector": item["vector"], "text": item["text"], - "metadata": item["metadata"], + "metadata": stringify_metadata(item["metadata"]), }, } for item in batch @@ -264,7 +266,7 @@ class ElasticsearchClient(VectorDBBase): "collection": collection_name, "vector": item["vector"], "text": item["text"], - "metadata": item["metadata"], + "metadata": stringify_metadata(item["metadata"]), }, "doc_as_upsert": True, } diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index a4bad13d00..6e07c28016 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -3,6 +3,8 @@ from pymilvus import FieldSchema, DataType import json import logging from typing import Optional + +from open_webui.retrieval.vector.utils import stringify_metadata from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, @@ -311,7 +313,7 @@ class MilvusClient(VectorDBBase): "id": item["id"], "vector": item["vector"], "data": {"text": item["text"]}, - "metadata": item["metadata"], + "metadata": stringify_metadata(item["metadata"]), } for item in items ], @@ -347,7 +349,7 @@ class MilvusClient(VectorDBBase): "id": item["id"], "vector": item["vector"], "data": {"text": item["text"]}, - "metadata": item["metadata"], + "metadata": stringify_metadata(item["metadata"]), } for item in items ], diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py index 7e16df3cfb..510070f97a 100644 --- a/backend/open_webui/retrieval/vector/dbs/opensearch.py +++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py @@ -2,6 +2,7 @@ from opensearchpy import OpenSearch from opensearchpy.helpers import bulk from typing import Optional +from open_webui.retrieval.vector.utils import stringify_metadata from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, @@ -200,7 +201,7 @@ class OpenSearchClient(VectorDBBase): "_source": { "vector": item["vector"], "text": item["text"], - "metadata": item["metadata"], + "metadata": stringify_metadata(item["metadata"]), }, } for item in batch @@ -222,7 +223,7 @@ class OpenSearchClient(VectorDBBase): "doc": { "vector": item["vector"], "text": item["text"], - "metadata": item["metadata"], + "metadata": stringify_metadata(item["metadata"]), }, "doc_as_upsert": True, } diff --git a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py new file mode 100644 index 0000000000..07b014681a --- /dev/null +++ b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py @@ -0,0 +1,943 @@ +""" +Oracle 23ai Vector Database Client - Fixed Version + +# .env +VECTOR_DB = "oracle23ai" + +## DBCS or oracle 23ai free +ORACLE_DB_USE_WALLET = false +ORACLE_DB_USER = "DEMOUSER" +ORACLE_DB_PASSWORD = "Welcome123456" +ORACLE_DB_DSN = "localhost:1521/FREEPDB1" + +## ADW or ATP +# ORACLE_DB_USE_WALLET = true +# ORACLE_DB_USER = "DEMOUSER" +# ORACLE_DB_PASSWORD = "Welcome123456" +# ORACLE_DB_DSN = "medium" +# ORACLE_DB_DSN = "(description= (retry_count=3)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=xx.oraclecloud.com))(connect_data=(service_name=yy.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))" +# ORACLE_WALLET_DIR = "/home/opc/adb_wallet" +# ORACLE_WALLET_PASSWORD = "Welcome1" + +ORACLE_VECTOR_LENGTH = 768 + +ORACLE_DB_POOL_MIN = 2 +ORACLE_DB_POOL_MAX = 10 +ORACLE_DB_POOL_INCREMENT = 1 +""" + +from typing import Optional, List, Dict, Any, Union +from decimal import Decimal +import logging +import os +import threading +import time +import json +import array +import oracledb + +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) + +from open_webui.config import ( + ORACLE_DB_USE_WALLET, + ORACLE_DB_USER, + ORACLE_DB_PASSWORD, + ORACLE_DB_DSN, + ORACLE_WALLET_DIR, + ORACLE_WALLET_PASSWORD, + ORACLE_VECTOR_LENGTH, + ORACLE_DB_POOL_MIN, + ORACLE_DB_POOL_MAX, + ORACLE_DB_POOL_INCREMENT, +) +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class Oracle23aiClient(VectorDBBase): + """ + Oracle Vector Database Client for vector similarity search using Oracle Database 23ai. + + This client provides an interface to store, retrieve, and search vector embeddings + in an Oracle database. It uses connection pooling for efficient database access + and supports vector similarity search operations. + + Attributes: + pool: Connection pool for Oracle database connections + """ + + def __init__(self) -> None: + """ + Initialize the Oracle23aiClient with a connection pool. + + Creates a connection pool with configurable min/max connections, initializes + the database schema if needed, and sets up necessary tables and indexes. + + Raises: + ValueError: If required configuration parameters are missing + Exception: If database initialization fails + """ + self.pool = None + + try: + # Create the appropriate connection pool based on DB type + if ORACLE_DB_USE_WALLET: + self._create_adb_pool() + else: # DBCS + self._create_dbcs_pool() + + dsn = ORACLE_DB_DSN + log.info(f"Creating Connection Pool [{ORACLE_DB_USER}:**@{dsn}]") + + with self.get_connection() as connection: + log.info(f"Connection version: {connection.version}") + self._initialize_database(connection) + + log.info("Oracle Vector Search initialization complete.") + except Exception as e: + log.exception(f"Error during Oracle Vector Search initialization: {e}") + raise + + def _create_adb_pool(self) -> None: + """ + Create connection pool for Oracle Autonomous Database. + + Uses wallet-based authentication. + """ + self.pool = oracledb.create_pool( + user=ORACLE_DB_USER, + password=ORACLE_DB_PASSWORD, + dsn=ORACLE_DB_DSN, + min=ORACLE_DB_POOL_MIN, + max=ORACLE_DB_POOL_MAX, + increment=ORACLE_DB_POOL_INCREMENT, + config_dir=ORACLE_WALLET_DIR, + wallet_location=ORACLE_WALLET_DIR, + wallet_password=ORACLE_WALLET_PASSWORD, + ) + log.info("Created ADB connection pool with wallet authentication.") + + def _create_dbcs_pool(self) -> None: + """ + Create connection pool for Oracle Database Cloud Service. + + Uses basic authentication without wallet. + """ + self.pool = oracledb.create_pool( + user=ORACLE_DB_USER, + password=ORACLE_DB_PASSWORD, + dsn=ORACLE_DB_DSN, + min=ORACLE_DB_POOL_MIN, + max=ORACLE_DB_POOL_MAX, + increment=ORACLE_DB_POOL_INCREMENT, + ) + log.info("Created DB connection pool with basic authentication.") + + def get_connection(self): + """ + Acquire a connection from the connection pool with retry logic. + + Returns: + connection: A database connection with output type handler configured + """ + max_retries = 3 + for attempt in range(max_retries): + try: + connection = self.pool.acquire() + connection.outputtypehandler = self._output_type_handler + return connection + except oracledb.DatabaseError as e: + (error_obj,) = e.args + log.exception( + f"Connection attempt {attempt + 1} failed: {error_obj.message}" + ) + + if attempt < max_retries - 1: + wait_time = 2**attempt + log.info(f"Retrying in {wait_time} seconds...") + time.sleep(wait_time) + else: + raise + + def start_health_monitor(self, interval_seconds: int = 60): + """ + Start a background thread to periodically check the health of the connection pool. + + Args: + interval_seconds (int): Number of seconds between health checks + """ + + def _monitor(): + while True: + try: + log.info("[HealthCheck] Running periodic DB health check...") + self.ensure_connection() + log.info("[HealthCheck] Connection is healthy.") + except Exception as e: + log.exception(f"[HealthCheck] Connection health check failed: {e}") + time.sleep(interval_seconds) + + thread = threading.Thread(target=_monitor, daemon=True) + thread.start() + log.info(f"Started DB health monitor every {interval_seconds} seconds.") + + def _reconnect_pool(self): + """ + Attempt to reinitialize the connection pool if it's been closed or broken. + """ + try: + log.info("Attempting to reinitialize the Oracle connection pool...") + + # Close existing pool if it exists + if self.pool: + try: + self.pool.close() + except Exception as close_error: + log.warning(f"Error closing existing pool: {close_error}") + + # Re-create the appropriate connection pool based on DB type + if ORACLE_DB_USE_WALLET: + self._create_adb_pool() + else: # DBCS + self._create_dbcs_pool() + + log.info("Connection pool reinitialized.") + except Exception as e: + log.exception(f"Failed to reinitialize the connection pool: {e}") + raise + + def ensure_connection(self): + """ + Ensure the database connection is alive, reconnecting pool if needed. + """ + try: + with self.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("SELECT 1 FROM dual") + except Exception as e: + log.exception( + f"Connection check failed: {e}, attempting to reconnect pool..." + ) + self._reconnect_pool() + + def _output_type_handler(self, cursor, metadata): + """ + Handle Oracle vector type conversion. + + Args: + cursor: Oracle database cursor + metadata: Metadata for the column + + Returns: + A variable with appropriate conversion for vector types + """ + if metadata.type_code is oracledb.DB_TYPE_VECTOR: + return cursor.var( + metadata.type_code, arraysize=cursor.arraysize, outconverter=list + ) + + def _initialize_database(self, connection) -> None: + """ + Initialize database schema, tables and indexes. + + Creates the document_chunk table and necessary indexes if they don't exist. + + Args: + connection: Oracle database connection + + Raises: + Exception: If schema initialization fails + """ + with connection.cursor() as cursor: + try: + log.info("Creating Table document_chunk") + cursor.execute( + """ + BEGIN + EXECUTE IMMEDIATE ' + CREATE TABLE IF NOT EXISTS document_chunk ( + id VARCHAR2(255) PRIMARY KEY, + collection_name VARCHAR2(255) NOT NULL, + text CLOB, + vmetadata JSON, + vector vector(*, float32) + ) + '; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; + """ + ) + + log.info("Creating Index document_chunk_collection_name_idx") + cursor.execute( + """ + BEGIN + EXECUTE IMMEDIATE ' + CREATE INDEX IF NOT EXISTS document_chunk_collection_name_idx + ON document_chunk (collection_name) + '; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; + """ + ) + + log.info("Creating VECTOR INDEX document_chunk_vector_ivf_idx") + cursor.execute( + """ + BEGIN + EXECUTE IMMEDIATE ' + CREATE VECTOR INDEX IF NOT EXISTS document_chunk_vector_ivf_idx + ON document_chunk(vector) + ORGANIZATION NEIGHBOR PARTITIONS + DISTANCE COSINE + WITH TARGET ACCURACY 95 + PARAMETERS (TYPE IVF, NEIGHBOR PARTITIONS 100) + '; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; + """ + ) + + connection.commit() + log.info("Database initialization completed successfully.") + + except Exception as e: + connection.rollback() + log.exception(f"Error during database initialization: {e}") + raise + + def check_vector_length(self) -> None: + """ + Check vector length compatibility (placeholder). + + This method would check if the configured vector length matches the database schema. + Currently implemented as a placeholder. + """ + pass + + def _vector_to_blob(self, vector: List[float]) -> bytes: + """ + Convert a vector to Oracle BLOB format. + + Args: + vector (List[float]): The vector to convert + + Returns: + bytes: The vector in Oracle BLOB format + """ + return array.array("f", vector) + + def adjust_vector_length(self, vector: List[float]) -> List[float]: + """ + Adjust vector to the expected length if needed. + + Args: + vector (List[float]): The vector to adjust + + Returns: + List[float]: The adjusted vector + """ + return vector + + def _decimal_handler(self, obj): + """ + Handle Decimal objects for JSON serialization. + + Args: + obj: Object to serialize + + Returns: + float: Converted decimal value + + Raises: + TypeError: If object is not JSON serializable + """ + if isinstance(obj, Decimal): + return float(obj) + raise TypeError(f"{obj} is not JSON serializable") + + def _metadata_to_json(self, metadata: Dict) -> str: + """ + Convert metadata dictionary to JSON string. + + Args: + metadata (Dict): Metadata dictionary + + Returns: + str: JSON representation of metadata + """ + return json.dumps(metadata, default=self._decimal_handler) if metadata else "{}" + + def _json_to_metadata(self, json_str: str) -> Dict: + """ + Convert JSON string to metadata dictionary. + + Args: + json_str (str): JSON string + + Returns: + Dict: Metadata dictionary + """ + return json.loads(json_str) if json_str else {} + + def insert(self, collection_name: str, items: List[VectorItem]) -> None: + """ + Insert vector items into the database. + + Args: + collection_name (str): Name of the collection + items (List[VectorItem]): List of vector items to insert + + Raises: + Exception: If insertion fails + + Example: + >>> client = Oracle23aiClient() + >>> items = [ + ... {"id": "1", "text": "Sample text", "vector": [0.1, 0.2, ...], "metadata": {"source": "doc1"}}, + ... {"id": "2", "text": "Another text", "vector": [0.3, 0.4, ...], "metadata": {"source": "doc2"}} + ... ] + >>> client.insert("my_collection", items) + """ + log.info(f"Inserting {len(items)} items into collection '{collection_name}'.") + + with self.get_connection() as connection: + try: + with connection.cursor() as cursor: + for item in items: + vector_blob = self._vector_to_blob(item["vector"]) + metadata_json = self._metadata_to_json(item["metadata"]) + + cursor.execute( + """ + INSERT INTO document_chunk + (id, collection_name, text, vmetadata, vector) + VALUES (:id, :collection_name, :text, :metadata, :vector) + """, + { + "id": item["id"], + "collection_name": collection_name, + "text": item["text"], + "metadata": metadata_json, + "vector": vector_blob, + }, + ) + + connection.commit() + log.info( + f"Successfully inserted {len(items)} items into collection '{collection_name}'." + ) + + except Exception as e: + connection.rollback() + log.exception(f"Error during insert: {e}") + raise + + def upsert(self, collection_name: str, items: List[VectorItem]) -> None: + """ + Update or insert vector items into the database. + + If an item with the same ID exists, it will be updated; + otherwise, it will be inserted. + + Args: + collection_name (str): Name of the collection + items (List[VectorItem]): List of vector items to upsert + + Raises: + Exception: If upsert operation fails + + Example: + >>> client = Oracle23aiClient() + >>> items = [ + ... {"id": "1", "text": "Updated text", "vector": [0.1, 0.2, ...], "metadata": {"source": "doc1"}}, + ... {"id": "3", "text": "New item", "vector": [0.5, 0.6, ...], "metadata": {"source": "doc3"}} + ... ] + >>> client.upsert("my_collection", items) + """ + log.info(f"Upserting {len(items)} items into collection '{collection_name}'.") + + with self.get_connection() as connection: + try: + with connection.cursor() as cursor: + for item in items: + vector_blob = self._vector_to_blob(item["vector"]) + metadata_json = self._metadata_to_json(item["metadata"]) + + cursor.execute( + """ + MERGE INTO document_chunk d + USING (SELECT :merge_id as id FROM dual) s + ON (d.id = s.id) + WHEN MATCHED THEN + UPDATE SET + collection_name = :upd_collection_name, + text = :upd_text, + vmetadata = :upd_metadata, + vector = :upd_vector + WHEN NOT MATCHED THEN + INSERT (id, collection_name, text, vmetadata, vector) + VALUES (:ins_id, :ins_collection_name, :ins_text, :ins_metadata, :ins_vector) + """, + { + "merge_id": item["id"], + "upd_collection_name": collection_name, + "upd_text": item["text"], + "upd_metadata": metadata_json, + "upd_vector": vector_blob, + "ins_id": item["id"], + "ins_collection_name": collection_name, + "ins_text": item["text"], + "ins_metadata": metadata_json, + "ins_vector": vector_blob, + }, + ) + + connection.commit() + log.info( + f"Successfully upserted {len(items)} items into collection '{collection_name}'." + ) + + except Exception as e: + connection.rollback() + log.exception(f"Error during upsert: {e}") + raise + + def search( + self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int + ) -> Optional[SearchResult]: + """ + Search for similar vectors in the database. + + Performs vector similarity search using cosine distance. + + Args: + collection_name (str): Name of the collection to search + vectors (List[List[Union[float, int]]]): Query vectors to find similar items for + limit (int): Maximum number of results to return per query + + Returns: + Optional[SearchResult]: Search results containing ids, distances, documents, and metadata + + Example: + >>> client = Oracle23aiClient() + >>> query_vector = [0.1, 0.2, 0.3, ...] # Must match VECTOR_LENGTH + >>> results = client.search("my_collection", [query_vector], limit=5) + >>> if results: + ... log.info(f"Found {len(results.ids[0])} matches") + ... for i, (id, dist) in enumerate(zip(results.ids[0], results.distances[0])): + ... log.info(f"Match {i+1}: id={id}, distance={dist}") + """ + log.info( + f"Searching items from collection '{collection_name}' with limit {limit}." + ) + + try: + if not vectors: + log.warning("No vectors provided for search.") + return None + + num_queries = len(vectors) + + ids = [[] for _ in range(num_queries)] + distances = [[] for _ in range(num_queries)] + documents = [[] for _ in range(num_queries)] + metadatas = [[] for _ in range(num_queries)] + + with self.get_connection() as connection: + with connection.cursor() as cursor: + for qid, vector in enumerate(vectors): + vector_blob = self._vector_to_blob(vector) + + cursor.execute( + """ + SELECT dc.id, dc.text, + JSON_SERIALIZE(dc.vmetadata RETURNING VARCHAR2(4096)) as vmetadata, + VECTOR_DISTANCE(dc.vector, :query_vector, COSINE) as distance + FROM document_chunk dc + WHERE dc.collection_name = :collection_name + ORDER BY VECTOR_DISTANCE(dc.vector, :query_vector, COSINE) + FETCH APPROX FIRST :limit ROWS ONLY + """, + { + "query_vector": vector_blob, + "collection_name": collection_name, + "limit": limit, + }, + ) + + results = cursor.fetchall() + + for row in results: + ids[qid].append(row[0]) + documents[qid].append( + row[1].read() + if isinstance(row[1], oracledb.LOB) + else str(row[1]) + ) + # 🔧 FIXED: Parse JSON metadata properly + metadata_str = ( + row[2].read() + if isinstance(row[2], oracledb.LOB) + else row[2] + ) + metadatas[qid].append(self._json_to_metadata(metadata_str)) + distances[qid].append(float(row[3])) + + log.info( + f"Search completed. Found {sum(len(ids[i]) for i in range(num_queries))} total results." + ) + + return SearchResult( + ids=ids, distances=distances, documents=documents, metadatas=metadatas + ) + + except Exception as e: + log.exception(f"Error during search: {e}") + return None + + def query( + self, collection_name: str, filter: Dict, limit: Optional[int] = None + ) -> Optional[GetResult]: + """ + Query items based on metadata filters. + + Retrieves items that match specified metadata criteria. + + Args: + collection_name (str): Name of the collection to query + filter (Dict[str, Any]): Metadata filters to apply + limit (Optional[int]): Maximum number of results to return + + Returns: + Optional[GetResult]: Query results containing ids, documents, and metadata + + Example: + >>> client = Oracle23aiClient() + >>> filter = {"source": "doc1", "category": "finance"} + >>> results = client.query("my_collection", filter, limit=20) + >>> if results: + ... print(f"Found {len(results.ids[0])} matching documents") + """ + log.info(f"Querying items from collection '{collection_name}' with filters.") + + try: + limit = limit or 100 + + query = """ + SELECT id, text, JSON_SERIALIZE(vmetadata RETURNING VARCHAR2(4096)) as vmetadata + FROM document_chunk + WHERE collection_name = :collection_name + """ + + params = {"collection_name": collection_name} + + for i, (key, value) in enumerate(filter.items()): + param_name = f"value_{i}" + query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}" + params[param_name] = str(value) + + query += " FETCH FIRST :limit ROWS ONLY" + params["limit"] = limit + + with self.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute(query, params) + results = cursor.fetchall() + + if not results: + log.info("No results found for query.") + return None + + ids = [[row[0] for row in results]] + documents = [ + [ + row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1]) + for row in results + ] + ] + # 🔧 FIXED: Parse JSON metadata properly + metadatas = [ + [ + self._json_to_metadata( + row[2].read() if isinstance(row[2], oracledb.LOB) else row[2] + ) + for row in results + ] + ] + + log.info(f"Query completed. Found {len(results)} results.") + + return GetResult(ids=ids, documents=documents, metadatas=metadatas) + + except Exception as e: + log.exception(f"Error during query: {e}") + return None + + def get(self, collection_name: str) -> Optional[GetResult]: + """ + Get all items in a collection. + + Retrieves items from a specified collection up to the limit. + + Args: + collection_name (str): Name of the collection to retrieve + limit (Optional[int]): Maximum number of items to retrieve + + Returns: + Optional[GetResult]: Result containing ids, documents, and metadata + + Example: + >>> client = Oracle23aiClient() + >>> results = client.get("my_collection", limit=50) + >>> if results: + ... print(f"Retrieved {len(results.ids[0])} documents from collection") + """ + log.info( + f"Getting items from collection '{collection_name}' with limit {limit}." + ) + + try: + limit = limit or 1000 + + with self.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT /*+ MONITOR */ id, text, JSON_SERIALIZE(vmetadata RETURNING VARCHAR2(4096)) as vmetadata + FROM document_chunk + WHERE collection_name = :collection_name + FETCH FIRST :limit ROWS ONLY + """, + {"collection_name": collection_name, "limit": limit}, + ) + + results = cursor.fetchall() + + if not results: + log.info("No results found.") + return None + + ids = [[row[0] for row in results]] + documents = [ + [ + row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1]) + for row in results + ] + ] + # 🔧 FIXED: Parse JSON metadata properly + metadatas = [ + [ + self._json_to_metadata( + row[2].read() if isinstance(row[2], oracledb.LOB) else row[2] + ) + for row in results + ] + ] + + return GetResult(ids=ids, documents=documents, metadatas=metadatas) + + except Exception as e: + log.exception(f"Error during get: {e}") + return None + + def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + filter: Optional[Dict[str, Any]] = None, + ) -> None: + """ + Delete items from the database. + + Deletes items from a collection based on IDs or metadata filters. + + Args: + collection_name (str): Name of the collection to delete from + ids (Optional[List[str]]): Specific item IDs to delete + filter (Optional[Dict[str, Any]]): Metadata filters for deletion + + Raises: + Exception: If deletion fails + + Example: + >>> client = Oracle23aiClient() + >>> # Delete specific items by ID + >>> client.delete("my_collection", ids=["1", "3", "5"]) + >>> # Or delete by metadata filter + >>> client.delete("my_collection", filter={"source": "deprecated_source"}) + """ + log.info(f"Deleting items from collection '{collection_name}'.") + + try: + query = ( + "DELETE FROM document_chunk WHERE collection_name = :collection_name" + ) + params = {"collection_name": collection_name} + + if ids: + # 🔧 FIXED: Use proper parameterized query to prevent SQL injection + placeholders = ",".join([f":id_{i}" for i in range(len(ids))]) + query += f" AND id IN ({placeholders})" + for i, id_val in enumerate(ids): + params[f"id_{i}"] = id_val + + if filter: + for i, (key, value) in enumerate(filter.items()): + param_name = f"value_{i}" + query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}" + params[param_name] = str(value) + + with self.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute(query, params) + deleted = cursor.rowcount + connection.commit() + + log.info(f"Deleted {deleted} items from collection '{collection_name}'.") + + except Exception as e: + log.exception(f"Error during delete: {e}") + raise + + def reset(self) -> None: + """ + Reset the database by deleting all items. + + Deletes all items from the document_chunk table. + + Raises: + Exception: If reset fails + + Example: + >>> client = Oracle23aiClient() + >>> client.reset() # Warning: Removes all data! + """ + log.info("Resetting database - deleting all items.") + + try: + with self.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("DELETE FROM document_chunk") + deleted = cursor.rowcount + connection.commit() + + log.info( + f"Reset complete. Deleted {deleted} items from 'document_chunk' table." + ) + + except Exception as e: + log.exception(f"Error during reset: {e}") + raise + + def close(self) -> None: + """ + Close the database connection pool. + + Properly closes the connection pool and releases all resources. + + Example: + >>> client = Oracle23aiClient() + >>> # After finishing all operations + >>> client.close() + """ + try: + if hasattr(self, "pool") and self.pool: + self.pool.close() + log.info("Oracle Vector Search connection pool closed.") + except Exception as e: + log.exception(f"Error closing connection pool: {e}") + + def has_collection(self, collection_name: str) -> bool: + """ + Check if a collection exists. + + Args: + collection_name (str): Name of the collection to check + + Returns: + bool: True if the collection exists, False otherwise + + Example: + >>> client = Oracle23aiClient() + >>> if client.has_collection("my_collection"): + ... print("Collection exists!") + ... else: + ... print("Collection does not exist.") + """ + try: + with self.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT COUNT(*) + FROM document_chunk + WHERE collection_name = :collection_name + FETCH FIRST 1 ROWS ONLY + """, + {"collection_name": collection_name}, + ) + + count = cursor.fetchone()[0] + + return count > 0 + + except Exception as e: + log.exception(f"Error checking collection existence: {e}") + return False + + def delete_collection(self, collection_name: str) -> None: + """ + Delete an entire collection. + + Removes all items belonging to the specified collection. + + Args: + collection_name (str): Name of the collection to delete + + Example: + >>> client = Oracle23aiClient() + >>> client.delete_collection("obsolete_collection") + """ + log.info(f"Deleting collection '{collection_name}'.") + + try: + with self.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + DELETE FROM document_chunk + WHERE collection_name = :collection_name + """, + {"collection_name": collection_name}, + ) + + deleted = cursor.rowcount + connection.commit() + + log.info( + f"Collection '{collection_name}' deleted. Removed {deleted} items." + ) + + except Exception as e: + log.exception(f"Error deleting collection '{collection_name}': {e}") + raise diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index 64f12aa6d0..9deb61f5a3 100644 --- a/backend/open_webui/retrieval/vector/dbs/pgvector.py +++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py @@ -26,6 +26,8 @@ from pgvector.sqlalchemy import Vector from sqlalchemy.ext.mutable import MutableDict from sqlalchemy.exc import NoSuchTableError + +from open_webui.retrieval.vector.utils import stringify_metadata from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, @@ -201,6 +203,8 @@ class PgvectorClient(VectorDBBase): for item in items: vector = self.adjust_vector_length(item["vector"]) # Use raw SQL for BYTEA/pgcrypto + # Ensure metadata is converted to its JSON text representation + json_metadata = json.dumps(item["metadata"]) self.session.execute( text( """ @@ -209,7 +213,7 @@ class PgvectorClient(VectorDBBase): VALUES ( :id, :vector, :collection_name, pgp_sym_encrypt(:text, :key), - pgp_sym_encrypt(:metadata::text, :key) + pgp_sym_encrypt(:metadata_text, :key) ) ON CONFLICT (id) DO NOTHING """ @@ -219,7 +223,7 @@ class PgvectorClient(VectorDBBase): "vector": vector, "collection_name": collection_name, "text": item["text"], - "metadata": json.dumps(item["metadata"]), + "metadata_text": json_metadata, "key": PGVECTOR_PGCRYPTO_KEY, }, ) @@ -235,7 +239,7 @@ class PgvectorClient(VectorDBBase): vector=vector, collection_name=collection_name, text=item["text"], - vmetadata=item["metadata"], + vmetadata=stringify_metadata(item["metadata"]), ) new_items.append(new_chunk) self.session.bulk_save_objects(new_items) @@ -253,6 +257,7 @@ class PgvectorClient(VectorDBBase): if PGVECTOR_PGCRYPTO: for item in items: vector = self.adjust_vector_length(item["vector"]) + json_metadata = json.dumps(item["metadata"]) self.session.execute( text( """ @@ -261,7 +266,7 @@ class PgvectorClient(VectorDBBase): VALUES ( :id, :vector, :collection_name, pgp_sym_encrypt(:text, :key), - pgp_sym_encrypt(:metadata::text, :key) + pgp_sym_encrypt(:metadata_text, :key) ) ON CONFLICT (id) DO UPDATE SET vector = EXCLUDED.vector, @@ -275,7 +280,7 @@ class PgvectorClient(VectorDBBase): "vector": vector, "collection_name": collection_name, "text": item["text"], - "metadata": json.dumps(item["metadata"]), + "metadata_text": json_metadata, "key": PGVECTOR_PGCRYPTO_KEY, }, ) @@ -292,7 +297,7 @@ class PgvectorClient(VectorDBBase): if existing: existing.vector = vector existing.text = item["text"] - existing.vmetadata = item["metadata"] + existing.vmetadata = stringify_metadata(item["metadata"]) existing.collection_name = ( collection_name # Update collection_name if necessary ) @@ -302,7 +307,7 @@ class PgvectorClient(VectorDBBase): vector=vector, collection_name=collection_name, text=item["text"], - vmetadata=item["metadata"], + vmetadata=stringify_metadata(item["metadata"]), ) self.session.add(new_chunk) self.session.commit() @@ -416,10 +421,12 @@ class PgvectorClient(VectorDBBase): documents[qid].append(row.text) metadatas[qid].append(row.vmetadata) + self.session.rollback() # read-only transaction return SearchResult( ids=ids, distances=distances, documents=documents, metadatas=metadatas ) except Exception as e: + self.session.rollback() log.exception(f"Error during search: {e}") return None @@ -472,12 +479,14 @@ class PgvectorClient(VectorDBBase): documents = [[result.text for result in results]] metadatas = [[result.vmetadata for result in results]] + self.session.rollback() # read-only transaction return GetResult( ids=ids, documents=documents, metadatas=metadatas, ) except Exception as e: + self.session.rollback() log.exception(f"Error during query: {e}") return None @@ -518,8 +527,10 @@ class PgvectorClient(VectorDBBase): documents = [[result.text for result in results]] metadatas = [[result.vmetadata for result in results]] + self.session.rollback() # read-only transaction return GetResult(ids=ids, documents=documents, metadatas=metadatas) except Exception as e: + self.session.rollback() log.exception(f"Error during get: {e}") return None @@ -587,8 +598,10 @@ class PgvectorClient(VectorDBBase): .first() is not None ) + self.session.rollback() # read-only transaction return exists except Exception as e: + self.session.rollback() log.exception(f"Error checking collection existence: {e}") return False diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py index 2276e713fc..ea43297499 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant.py @@ -19,6 +19,8 @@ from open_webui.config import ( QDRANT_GRPC_PORT, QDRANT_PREFER_GRPC, QDRANT_COLLECTION_PREFIX, + QDRANT_TIMEOUT, + QDRANT_HNSW_M, ) from open_webui.env import SRC_LOG_LEVELS @@ -36,6 +38,8 @@ class QdrantClient(VectorDBBase): self.QDRANT_ON_DISK = QDRANT_ON_DISK self.PREFER_GRPC = QDRANT_PREFER_GRPC self.GRPC_PORT = QDRANT_GRPC_PORT + self.QDRANT_TIMEOUT = QDRANT_TIMEOUT + self.QDRANT_HNSW_M = QDRANT_HNSW_M if not self.QDRANT_URI: self.client = None @@ -53,9 +57,14 @@ class QdrantClient(VectorDBBase): grpc_port=self.GRPC_PORT, prefer_grpc=self.PREFER_GRPC, api_key=self.QDRANT_API_KEY, + timeout=self.QDRANT_TIMEOUT, ) else: - self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY) + self.client = Qclient( + url=self.QDRANT_URI, + api_key=self.QDRANT_API_KEY, + timeout=QDRANT_TIMEOUT, + ) def _result_to_get_result(self, points) -> GetResult: ids = [] @@ -85,6 +94,9 @@ class QdrantClient(VectorDBBase): distance=models.Distance.COSINE, on_disk=self.QDRANT_ON_DISK, ), + hnsw_config=models.HnswConfigDiff( + m=self.QDRANT_HNSW_M, + ), ) # Create payload indexes for efficient filtering @@ -171,23 +183,23 @@ class QdrantClient(VectorDBBase): ) ) - points = self.client.query_points( + points = self.client.scroll( collection_name=f"{self.collection_prefix}_{collection_name}", - query_filter=models.Filter(should=field_conditions), + scroll_filter=models.Filter(should=field_conditions), limit=limit, ) - return self._result_to_get_result(points.points) + return self._result_to_get_result(points[0]) except Exception as e: log.exception(f"Error querying a collection '{collection_name}': {e}") return None def get(self, collection_name: str) -> Optional[GetResult]: # Get all the items in the collection. - points = self.client.query_points( + points = self.client.scroll( collection_name=f"{self.collection_prefix}_{collection_name}", limit=NO_LIMIT, # otherwise qdrant would set limit to 10! ) - return self._result_to_get_result(points.points) + return self._result_to_get_result(points[0]) def insert(self, collection_name: str, items: list[VectorItem]): # Insert the items into the collection, if the collection does not exist, it will be created. diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py index 17c054ee50..ed4a8bab34 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py @@ -10,6 +10,8 @@ from open_webui.config import ( QDRANT_PREFER_GRPC, QDRANT_URI, QDRANT_COLLECTION_PREFIX, + QDRANT_TIMEOUT, + QDRANT_HNSW_M, ) from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.vector.main import ( @@ -51,6 +53,8 @@ class QdrantClient(VectorDBBase): self.QDRANT_ON_DISK = QDRANT_ON_DISK self.PREFER_GRPC = QDRANT_PREFER_GRPC self.GRPC_PORT = QDRANT_GRPC_PORT + self.QDRANT_TIMEOUT = QDRANT_TIMEOUT + self.QDRANT_HNSW_M = QDRANT_HNSW_M if not self.QDRANT_URI: raise ValueError( @@ -69,9 +73,14 @@ class QdrantClient(VectorDBBase): grpc_port=self.GRPC_PORT, prefer_grpc=self.PREFER_GRPC, api_key=self.QDRANT_API_KEY, + timeout=self.QDRANT_TIMEOUT, ) if self.PREFER_GRPC - else Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY) + else Qclient( + url=self.QDRANT_URI, + api_key=self.QDRANT_API_KEY, + timeout=self.QDRANT_TIMEOUT, + ) ) # Main collection types for multi-tenancy @@ -133,6 +142,12 @@ class QdrantClient(VectorDBBase): distance=models.Distance.COSINE, on_disk=self.QDRANT_ON_DISK, ), + # Disable global index building due to multitenancy + # For more details https://qdrant.tech/documentation/guides/multiple-partitions/#calibrate-performance + hnsw_config=models.HnswConfigDiff( + payload_m=self.QDRANT_HNSW_M, + m=0, + ), ) log.info( f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!" @@ -278,12 +293,12 @@ class QdrantClient(VectorDBBase): tenant_filter = _tenant_filter(tenant_id) field_conditions = [_metadata_filter(k, v) for k, v in filter.items()] combined_filter = models.Filter(must=[tenant_filter, *field_conditions]) - points = self.client.query_points( + points = self.client.scroll( collection_name=mt_collection, - query_filter=combined_filter, + scroll_filter=combined_filter, limit=limit, ) - return self._result_to_get_result(points.points) + return self._result_to_get_result(points[0]) def get(self, collection_name: str) -> Optional[GetResult]: """ @@ -296,12 +311,12 @@ class QdrantClient(VectorDBBase): log.debug(f"Collection {mt_collection} doesn't exist, get returns None") return None tenant_filter = _tenant_filter(tenant_id) - points = self.client.query_points( + points = self.client.scroll( collection_name=mt_collection, - query_filter=models.Filter(must=[tenant_filter]), + scroll_filter=models.Filter(must=[tenant_filter]), limit=NO_LIMIT, ) - return self._result_to_get_result(points.points) + return self._result_to_get_result(points[0]) def upsert(self, collection_name: str, items: List[VectorItem]): """ diff --git a/backend/open_webui/retrieval/vector/dbs/s3vector.py b/backend/open_webui/retrieval/vector/dbs/s3vector.py new file mode 100644 index 0000000000..74253a3b36 --- /dev/null +++ b/backend/open_webui/retrieval/vector/dbs/s3vector.py @@ -0,0 +1,752 @@ +from backend.open_webui.retrieval.vector.utils import stringify_metadata +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + GetResult, + SearchResult, +) +from open_webui.config import S3_VECTOR_BUCKET_NAME, S3_VECTOR_REGION +from open_webui.env import SRC_LOG_LEVELS +from typing import List, Optional, Dict, Any, Union +import logging +import boto3 + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class S3VectorClient(VectorDBBase): + """ + AWS S3 Vector integration for Open WebUI Knowledge. + """ + + def __init__(self): + self.bucket_name = S3_VECTOR_BUCKET_NAME + self.region = S3_VECTOR_REGION + + # Simple validation - log warnings instead of raising exceptions + if not self.bucket_name: + log.warning("S3_VECTOR_BUCKET_NAME not set - S3Vector will not work") + if not self.region: + log.warning("S3_VECTOR_REGION not set - S3Vector will not work") + + if self.bucket_name and self.region: + try: + self.client = boto3.client("s3vectors", region_name=self.region) + log.info( + f"S3Vector client initialized for bucket '{self.bucket_name}' in region '{self.region}'" + ) + except Exception as e: + log.error(f"Failed to initialize S3Vector client: {e}") + self.client = None + else: + self.client = None + + def _create_index( + self, + index_name: str, + dimension: int, + data_type: str = "float32", + distance_metric: str = "cosine", + ) -> None: + """ + Create a new index in the S3 vector bucket for the given collection if it does not exist. + """ + if self.has_collection(index_name): + log.debug(f"Index '{index_name}' already exists, skipping creation") + return + + try: + self.client.create_index( + vectorBucketName=self.bucket_name, + indexName=index_name, + dataType=data_type, + dimension=dimension, + distanceMetric=distance_metric, + ) + log.info( + f"Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})" + ) + except Exception as e: + log.error(f"Error creating S3 index '{index_name}': {e}") + raise + + def _filter_metadata( + self, metadata: Dict[str, Any], item_id: str + ) -> Dict[str, Any]: + """ + Filter vector metadata keys to comply with S3 Vector API limit of 10 keys maximum. + """ + if not isinstance(metadata, dict) or len(metadata) <= 10: + return metadata + + # Keep only the first 10 keys, prioritizing important ones based on actual Open WebUI metadata + important_keys = [ + "text", # The actual document content + "file_id", # File ID + "source", # Document source file + "title", # Document title + "page", # Page number + "total_pages", # Total pages in document + "embedding_config", # Embedding configuration + "created_by", # User who created it + "name", # Document name + "hash", # Content hash + ] + filtered_metadata = {} + + # First, add important keys if they exist + for key in important_keys: + if key in metadata: + filtered_metadata[key] = metadata[key] + if len(filtered_metadata) >= 10: + break + + # If we still have room, add other keys + if len(filtered_metadata) < 10: + for key, value in metadata.items(): + if key not in filtered_metadata: + filtered_metadata[key] = value + if len(filtered_metadata) >= 10: + break + + log.warning( + f"Metadata for key '{item_id}' had {len(metadata)} keys, limited to 10 keys" + ) + return filtered_metadata + + def has_collection(self, collection_name: str) -> bool: + """ + Check if a vector index (collection) exists in the S3 vector bucket. + """ + + try: + response = self.client.list_indexes(vectorBucketName=self.bucket_name) + indexes = response.get("indexes", []) + return any(idx.get("indexName") == collection_name for idx in indexes) + except Exception as e: + log.error(f"Error listing indexes: {e}") + return False + + def delete_collection(self, collection_name: str) -> None: + """ + Delete an entire S3 Vector index/collection. + """ + + if not self.has_collection(collection_name): + log.warning( + f"Collection '{collection_name}' does not exist, nothing to delete" + ) + return + + try: + log.info(f"Deleting collection '{collection_name}'") + self.client.delete_index( + vectorBucketName=self.bucket_name, indexName=collection_name + ) + log.info(f"Successfully deleted collection '{collection_name}'") + except Exception as e: + log.error(f"Error deleting collection '{collection_name}': {e}") + raise + + def insert(self, collection_name: str, items: List[VectorItem]) -> None: + """ + Insert vector items into the S3 Vector index. Create index if it does not exist. + """ + if not items: + log.warning("No items to insert") + return + + dimension = len(items[0]["vector"]) + + try: + if not self.has_collection(collection_name): + log.info(f"Index '{collection_name}' does not exist. Creating index.") + self._create_index( + index_name=collection_name, + dimension=dimension, + data_type="float32", + distance_metric="cosine", + ) + + # Prepare vectors for insertion + vectors = [] + for item in items: + # Ensure vector data is in the correct format for S3 Vector API + vector_data = item["vector"] + if isinstance(vector_data, list): + # Convert list to float32 values as required by S3 Vector API + vector_data = [float(x) for x in vector_data] + + # Prepare metadata, ensuring the text field is preserved + metadata = item.get("metadata", {}).copy() + + # Add the text field to metadata so it's available for retrieval + metadata["text"] = item["text"] + + # Convert metadata to string format for consistency + metadata = stringify_metadata(metadata) + + # Filter metadata to comply with S3 Vector API limit of 10 keys + metadata = self._filter_metadata(metadata, item["id"]) + + vectors.append( + { + "key": item["id"], + "data": {"float32": vector_data}, + "metadata": metadata, + } + ) + # Insert vectors + self.client.put_vectors( + vectorBucketName=self.bucket_name, + indexName=collection_name, + vectors=vectors, + ) + log.info(f"Inserted {len(vectors)} vectors into index '{collection_name}'.") + except Exception as e: + log.error(f"Error inserting vectors: {e}") + raise + + def upsert(self, collection_name: str, items: List[VectorItem]) -> None: + """ + Insert or update vector items in the S3 Vector index. Create index if it does not exist. + """ + if not items: + log.warning("No items to upsert") + return + + dimension = len(items[0]["vector"]) + log.info(f"Upsert dimension: {dimension}") + + try: + if not self.has_collection(collection_name): + log.info( + f"Index '{collection_name}' does not exist. Creating index for upsert." + ) + self._create_index( + index_name=collection_name, + dimension=dimension, + data_type="float32", + distance_metric="cosine", + ) + + # Prepare vectors for upsert + vectors = [] + for item in items: + # Ensure vector data is in the correct format for S3 Vector API + vector_data = item["vector"] + if isinstance(vector_data, list): + # Convert list to float32 values as required by S3 Vector API + vector_data = [float(x) for x in vector_data] + + # Prepare metadata, ensuring the text field is preserved + metadata = item.get("metadata", {}).copy() + # Add the text field to metadata so it's available for retrieval + metadata["text"] = item["text"] + + # Convert metadata to string format for consistency + metadata = stringify_metadata(metadata) + + # Filter metadata to comply with S3 Vector API limit of 10 keys + metadata = self._filter_metadata(metadata, item["id"]) + + vectors.append( + { + "key": item["id"], + "data": {"float32": vector_data}, + "metadata": metadata, + } + ) + # Upsert vectors (using put_vectors for upsert semantics) + log.info( + f"Upserting {len(vectors)} vectors. First vector sample: key={vectors[0]['key']}, data_type={type(vectors[0]['data']['float32'])}, data_len={len(vectors[0]['data']['float32'])}" + ) + self.client.put_vectors( + vectorBucketName=self.bucket_name, + indexName=collection_name, + vectors=vectors, + ) + log.info(f"Upserted {len(vectors)} vectors into index '{collection_name}'.") + except Exception as e: + log.error(f"Error upserting vectors: {e}") + raise + + def search( + self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int + ) -> Optional[SearchResult]: + """ + Search for similar vectors in a collection using multiple query vectors. + """ + + if not self.has_collection(collection_name): + log.warning(f"Collection '{collection_name}' does not exist") + return None + + if not vectors: + log.warning("No query vectors provided") + return None + + try: + log.info( + f"Searching collection '{collection_name}' with {len(vectors)} query vectors, limit={limit}" + ) + + # Initialize result lists + all_ids = [] + all_documents = [] + all_metadatas = [] + all_distances = [] + + # Process each query vector + for i, query_vector in enumerate(vectors): + log.debug(f"Processing query vector {i+1}/{len(vectors)}") + + # Prepare the query vector in S3 Vector format + query_vector_dict = {"float32": [float(x) for x in query_vector]} + + # Call S3 Vector query API + response = self.client.query_vectors( + vectorBucketName=self.bucket_name, + indexName=collection_name, + topK=limit, + queryVector=query_vector_dict, + returnMetadata=True, + returnDistance=True, + ) + + # Process results for this query + query_ids = [] + query_documents = [] + query_metadatas = [] + query_distances = [] + + result_vectors = response.get("vectors", []) + + for vector in result_vectors: + vector_id = vector.get("key") + vector_metadata = vector.get("metadata", {}) + vector_distance = vector.get("distance", 0.0) + + # Extract document text from metadata + document_text = "" + if isinstance(vector_metadata, dict): + # Get the text field first (highest priority) + document_text = vector_metadata.get("text") + if not document_text: + # Fallback to other possible text fields + document_text = ( + vector_metadata.get("content") + or vector_metadata.get("document") + or vector_id + ) + else: + document_text = vector_id + + query_ids.append(vector_id) + query_documents.append(document_text) + query_metadatas.append(vector_metadata) + query_distances.append(vector_distance) + + # Add this query's results to the overall results + all_ids.append(query_ids) + all_documents.append(query_documents) + all_metadatas.append(query_metadatas) + all_distances.append(query_distances) + + log.info(f"Search completed. Found results for {len(all_ids)} queries") + + # Return SearchResult format + return SearchResult( + ids=all_ids if all_ids else None, + documents=all_documents if all_documents else None, + metadatas=all_metadatas if all_metadatas else None, + distances=all_distances if all_distances else None, + ) + + except Exception as e: + log.error(f"Error searching collection '{collection_name}': {str(e)}") + # Handle specific AWS exceptions + if hasattr(e, "response") and "Error" in e.response: + error_code = e.response["Error"]["Code"] + if error_code == "NotFoundException": + log.warning(f"Collection '{collection_name}' not found") + return None + elif error_code == "ValidationException": + log.error(f"Invalid query vector dimensions or parameters") + return None + elif error_code == "AccessDeniedException": + log.error( + f"Access denied for collection '{collection_name}'. Check permissions." + ) + return None + raise + + def query( + self, collection_name: str, filter: Dict, limit: Optional[int] = None + ) -> Optional[GetResult]: + """ + Query vectors from a collection using metadata filter. + """ + + if not self.has_collection(collection_name): + log.warning(f"Collection '{collection_name}' does not exist") + return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) + + if not filter: + log.warning("No filter provided, returning all vectors") + return self.get(collection_name) + + try: + log.info(f"Querying collection '{collection_name}' with filter: {filter}") + + # For S3 Vector, we need to use list_vectors and then filter results + # Since S3 Vector may not support complex server-side filtering, + # we'll retrieve all vectors and filter client-side + + # Get all vectors first + all_vectors_result = self.get(collection_name) + + if not all_vectors_result or not all_vectors_result.ids: + log.warning("No vectors found in collection") + return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) + + # Extract the lists from the result + all_ids = all_vectors_result.ids[0] if all_vectors_result.ids else [] + all_documents = ( + all_vectors_result.documents[0] if all_vectors_result.documents else [] + ) + all_metadatas = ( + all_vectors_result.metadatas[0] if all_vectors_result.metadatas else [] + ) + + # Apply client-side filtering + filtered_ids = [] + filtered_documents = [] + filtered_metadatas = [] + + for i, metadata in enumerate(all_metadatas): + if self._matches_filter(metadata, filter): + if i < len(all_ids): + filtered_ids.append(all_ids[i]) + if i < len(all_documents): + filtered_documents.append(all_documents[i]) + filtered_metadatas.append(metadata) + + # Apply limit if specified + if limit and len(filtered_ids) >= limit: + break + + log.info( + f"Filter applied: {len(filtered_ids)} vectors match out of {len(all_ids)} total" + ) + + # Return GetResult format + if filtered_ids: + return GetResult( + ids=[filtered_ids], + documents=[filtered_documents], + metadatas=[filtered_metadatas], + ) + else: + return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) + + except Exception as e: + log.error(f"Error querying collection '{collection_name}': {str(e)}") + # Handle specific AWS exceptions + if hasattr(e, "response") and "Error" in e.response: + error_code = e.response["Error"]["Code"] + if error_code == "NotFoundException": + log.warning(f"Collection '{collection_name}' not found") + return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) + elif error_code == "AccessDeniedException": + log.error( + f"Access denied for collection '{collection_name}'. Check permissions." + ) + return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) + raise + + def get(self, collection_name: str) -> Optional[GetResult]: + """ + Retrieve all vectors from a collection. + """ + + if not self.has_collection(collection_name): + log.warning(f"Collection '{collection_name}' does not exist") + return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) + + try: + log.info(f"Retrieving all vectors from collection '{collection_name}'") + + # Initialize result lists + all_ids = [] + all_documents = [] + all_metadatas = [] + + # Handle pagination + next_token = None + + while True: + # Prepare request parameters + request_params = { + "vectorBucketName": self.bucket_name, + "indexName": collection_name, + "returnData": False, # Don't include vector data (not needed for get) + "returnMetadata": True, # Include metadata + "maxResults": 500, # Use reasonable page size + } + + if next_token: + request_params["nextToken"] = next_token + + # Call S3 Vector API + response = self.client.list_vectors(**request_params) + + # Process vectors in this page + vectors = response.get("vectors", []) + + for vector in vectors: + vector_id = vector.get("key") + vector_data = vector.get("data", {}) + vector_metadata = vector.get("metadata", {}) + + # Extract the actual vector array + vector_array = vector_data.get("float32", []) + + # For documents, we try to extract text from metadata or use the vector ID + document_text = "" + if isinstance(vector_metadata, dict): + # Get the text field first (highest priority) + document_text = vector_metadata.get("text") + if not document_text: + # Fallback to other possible text fields + document_text = ( + vector_metadata.get("content") + or vector_metadata.get("document") + or vector_id + ) + + # Log the actual content for debugging + log.debug( + f"Document text preview (first 200 chars): {str(document_text)[:200]}" + ) + else: + document_text = vector_id + + all_ids.append(vector_id) + all_documents.append(document_text) + all_metadatas.append(vector_metadata) + + # Check if there are more pages + next_token = response.get("nextToken") + if not next_token: + break + + log.info( + f"Retrieved {len(all_ids)} vectors from collection '{collection_name}'" + ) + + # Return in GetResult format + # The Open WebUI GetResult expects lists of lists, so we wrap each list + if all_ids: + return GetResult( + ids=[all_ids], documents=[all_documents], metadatas=[all_metadatas] + ) + else: + return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) + + except Exception as e: + log.error( + f"Error retrieving vectors from collection '{collection_name}': {str(e)}" + ) + # Handle specific AWS exceptions + if hasattr(e, "response") and "Error" in e.response: + error_code = e.response["Error"]["Code"] + if error_code == "NotFoundException": + log.warning(f"Collection '{collection_name}' not found") + return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) + elif error_code == "AccessDeniedException": + log.error( + f"Access denied for collection '{collection_name}'. Check permissions." + ) + return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) + raise + + def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + filter: Optional[Dict] = None, + ) -> None: + """ + Delete vectors by ID or filter from a collection. + """ + + if not self.has_collection(collection_name): + log.warning( + f"Collection '{collection_name}' does not exist, nothing to delete" + ) + return + + # Check if this is a knowledge collection (not file-specific) + is_knowledge_collection = not collection_name.startswith("file-") + + try: + if ids: + # Delete by specific vector IDs/keys + log.info( + f"Deleting {len(ids)} vectors by IDs from collection '{collection_name}'" + ) + self.client.delete_vectors( + vectorBucketName=self.bucket_name, + indexName=collection_name, + keys=ids, + ) + log.info(f"Deleted {len(ids)} vectors from index '{collection_name}'") + + elif filter: + # Handle filter-based deletion + log.info( + f"Deleting vectors by filter from collection '{collection_name}': {filter}" + ) + + # If this is a knowledge collection and we have a file_id filter, + # also clean up the corresponding file-specific collection + if is_knowledge_collection and "file_id" in filter: + file_id = filter["file_id"] + file_collection_name = f"file-{file_id}" + if self.has_collection(file_collection_name): + log.info( + f"Found related file-specific collection '{file_collection_name}', deleting it to prevent duplicates" + ) + self.delete_collection(file_collection_name) + + # For the main collection, implement query-then-delete + # First, query to get IDs matching the filter + query_result = self.query(collection_name, filter) + if query_result and query_result.ids and query_result.ids[0]: + matching_ids = query_result.ids[0] + log.info( + f"Found {len(matching_ids)} vectors matching filter, deleting them" + ) + + # Delete the matching vectors by ID + self.client.delete_vectors( + vectorBucketName=self.bucket_name, + indexName=collection_name, + keys=matching_ids, + ) + log.info( + f"Deleted {len(matching_ids)} vectors from index '{collection_name}' using filter" + ) + else: + log.warning("No vectors found matching the filter criteria") + else: + log.warning("No IDs or filter provided for deletion") + except Exception as e: + log.error( + f"Error deleting vectors from collection '{collection_name}': {e}" + ) + raise + + def reset(self) -> None: + """ + Reset/clear all vector data. For S3 Vector, this deletes all indexes. + """ + + try: + log.warning( + "Reset called - this will delete all vector indexes in the S3 bucket" + ) + + # List all indexes + response = self.client.list_indexes(vectorBucketName=self.bucket_name) + indexes = response.get("indexes", []) + + if not indexes: + log.warning("No indexes found to delete") + return + + # Delete all indexes + deleted_count = 0 + for index in indexes: + index_name = index.get("indexName") + if index_name: + try: + self.client.delete_index( + vectorBucketName=self.bucket_name, indexName=index_name + ) + deleted_count += 1 + log.info(f"Deleted index: {index_name}") + except Exception as e: + log.error(f"Error deleting index '{index_name}': {e}") + + log.info(f"Reset completed: deleted {deleted_count} indexes") + + except Exception as e: + log.error(f"Error during reset: {e}") + raise + + def _matches_filter(self, metadata: Dict[str, Any], filter: Dict[str, Any]) -> bool: + """ + Check if metadata matches the given filter conditions. + """ + if not isinstance(metadata, dict) or not isinstance(filter, dict): + return False + + # Check each filter condition + for key, expected_value in filter.items(): + # Handle special operators + if key.startswith("$"): + if key == "$and": + # All conditions must match + if not isinstance(expected_value, list): + continue + for condition in expected_value: + if not self._matches_filter(metadata, condition): + return False + elif key == "$or": + # At least one condition must match + if not isinstance(expected_value, list): + continue + any_match = False + for condition in expected_value: + if self._matches_filter(metadata, condition): + any_match = True + break + if not any_match: + return False + continue + + # Get the actual value from metadata + actual_value = metadata.get(key) + + # Handle different types of expected values + if isinstance(expected_value, dict): + # Handle comparison operators + for op, op_value in expected_value.items(): + if op == "$eq": + if actual_value != op_value: + return False + elif op == "$ne": + if actual_value == op_value: + return False + elif op == "$in": + if ( + not isinstance(op_value, list) + or actual_value not in op_value + ): + return False + elif op == "$nin": + if isinstance(op_value, list) and actual_value in op_value: + return False + elif op == "$exists": + if bool(op_value) != (key in metadata): + return False + # Add more operators as needed + else: + # Simple equality check + if actual_value != expected_value: + return False + + return True diff --git a/backend/open_webui/retrieval/vector/factory.py b/backend/open_webui/retrieval/vector/factory.py index 72a3f6cebe..36cb85c948 100644 --- a/backend/open_webui/retrieval/vector/factory.py +++ b/backend/open_webui/retrieval/vector/factory.py @@ -30,6 +30,10 @@ class Vector: from open_webui.retrieval.vector.dbs.pinecone import PineconeClient return PineconeClient() + case VectorType.S3VECTOR: + from open_webui.retrieval.vector.dbs.s3vector import S3VectorClient + + return S3VectorClient() case VectorType.OPENSEARCH: from open_webui.retrieval.vector.dbs.opensearch import OpenSearchClient @@ -48,6 +52,10 @@ class Vector: from open_webui.retrieval.vector.dbs.chroma import ChromaClient return ChromaClient() + case VectorType.ORACLE23AI: + from open_webui.retrieval.vector.dbs.oracle23ai import Oracle23aiClient + + return Oracle23aiClient() case _: raise ValueError(f"Unsupported vector type: {vector_type}") diff --git a/backend/open_webui/retrieval/vector/type.py b/backend/open_webui/retrieval/vector/type.py index b03bcb4828..7e517c169c 100644 --- a/backend/open_webui/retrieval/vector/type.py +++ b/backend/open_webui/retrieval/vector/type.py @@ -9,3 +9,5 @@ class VectorType(StrEnum): ELASTICSEARCH = "elasticsearch" OPENSEARCH = "opensearch" PGVECTOR = "pgvector" + ORACLE23AI = "oracle23ai" + S3VECTOR = "s3vector" diff --git a/backend/open_webui/retrieval/vector/utils.py b/backend/open_webui/retrieval/vector/utils.py new file mode 100644 index 0000000000..1d9698c6b1 --- /dev/null +++ b/backend/open_webui/retrieval/vector/utils.py @@ -0,0 +1,14 @@ +from datetime import datetime + + +def stringify_metadata( + metadata: dict[str, any], +) -> dict[str, any]: + for key, value in metadata.items(): + if ( + isinstance(value, datetime) + or isinstance(value, list) + or isinstance(value, dict) + ): + metadata[key] = str(value) + return metadata diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index a9aa93e08a..b1b715d44b 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -561,7 +561,11 @@ def transcription_handler(request, file_path, metadata): file_path, beam_size=5, vad_filter=request.app.state.config.WHISPER_VAD_FILTER, - language=metadata.get("language") or WHISPER_LANGUAGE, + language=( + metadata.get("language", None) + if WHISPER_LANGUAGE == "" + else WHISPER_LANGUAGE + ), ) log.info( "Detected language '%s' with probability %f" diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index fd3c317ab9..e157e5527d 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -351,11 +351,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): user = Users.get_user_by_email(email) if not user: try: - user_count = Users.get_num_users() - role = ( "admin" - if user_count == 0 + if not Users.has_users() else request.app.state.config.DEFAULT_USER_ROLE ) @@ -489,7 +487,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm): if Users.get_user_by_email(admin_email.lower()): user = Auths.authenticate_user(admin_email.lower(), admin_password) else: - if Users.get_num_users() != 0: + if Users.has_users(): raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS) await signup( @@ -556,6 +554,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm): @router.post("/signup", response_model=SessionUserResponse) async def signup(request: Request, response: Response, form_data: SignupForm): + has_users = Users.has_users() if WEBUI_AUTH: if ( @@ -566,12 +565,11 @@ async def signup(request: Request, response: Response, form_data: SignupForm): status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED ) else: - if Users.get_num_users() != 0: + if has_users: raise HTTPException( status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED ) - user_count = Users.get_num_users() if not validate_email_format(form_data.email.lower()): raise HTTPException( status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT @@ -581,9 +579,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm): raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) try: - role = ( - "admin" if user_count == 0 else request.app.state.config.DEFAULT_USER_ROLE - ) + role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE # The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing. if len(form_data.password.encode("utf-8")) > 72: @@ -644,7 +640,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm): user.id, request.app.state.config.USER_PERMISSIONS ) - if user_count == 0: + if not has_users: # Disable signup after the first user is created request.app.state.config.ENABLE_SIGNUP = False @@ -673,7 +669,7 @@ async def signout(request: Request, response: Response): if ENABLE_OAUTH_SIGNUP.value: oauth_id_token = request.cookies.get("oauth_id_token") - if oauth_id_token: + if oauth_id_token and OPENID_PROVIDER_URL.value: try: async with ClientSession(trust_env=True) as session: async with session.get(OPENID_PROVIDER_URL.value) as resp: diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index a4173fbd8d..e4390e23f6 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -434,13 +434,6 @@ async def update_message_by_id( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if user.role != "admin" and not has_access( - user.id, type="read", access_control=channel.access_control - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) - message = Messages.get_message_by_id(message_id) if not message: raise HTTPException( @@ -452,6 +445,15 @@ async def update_message_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() ) + if ( + user.role != "admin" + and message.user_id != user.id + and not has_access(user.id, type="read", access_control=channel.access_control) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + try: message = Messages.update_message_by_id(message_id, form_data) message = Messages.get_message_by_id(message_id) @@ -641,13 +643,6 @@ async def delete_message_by_id( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if user.role != "admin" and not has_access( - user.id, type="read", access_control=channel.access_control - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) - message = Messages.get_message_by_id(message_id) if not message: raise HTTPException( @@ -659,6 +654,15 @@ async def delete_message_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() ) + if ( + user.role != "admin" + and message.user_id != user.id + and not has_access(user.id, type="read", access_control=channel.access_control) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + try: Messages.delete_message_by_id(message_id) await sio.emit( diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 628f9176b8..ba16b506f7 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -617,7 +617,18 @@ async def clone_chat_by_id( "title": form_data.title if form_data.title else f"Clone of {chat.title}", } - chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat})) + chat = Chats.import_chat( + user.id, + ChatImportForm( + **{ + "chat": updated_chat, + "meta": chat.meta, + "pinned": chat.pinned, + "folder_id": chat.folder_id, + } + ), + ) + return ChatResponse(**chat.model_dump()) else: raise HTTPException( @@ -646,7 +657,17 @@ async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)): "title": f"Clone of {chat.title}", } - chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat})) + chat = Chats.import_chat( + user.id, + ChatImportForm( + **{ + "chat": updated_chat, + "meta": chat.meta, + "pinned": chat.pinned, + "folder_id": chat.folder_id, + } + ), + ) return ChatResponse(**chat.model_dump()) else: raise HTTPException( diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index a329584ca2..c8badfa112 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -7,7 +7,11 @@ from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.config import get_config, save_config from open_webui.config import BannerModel -from open_webui.utils.tools import get_tool_server_data, get_tool_servers_data +from open_webui.utils.tools import ( + get_tool_server_data, + get_tool_servers_data, + get_tool_server_url, +) router = APIRouter() @@ -135,7 +139,7 @@ async def verify_tool_servers_config( elif form_data.auth_type == "session": token = request.state.token.credentials - url = f"{form_data.url}/{form_data.path}" + url = get_tool_server_url(form_data.url, form_data.path) return await get_tool_server_data(token, url) except Exception as e: raise HTTPException( diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index 164f3c40b4..c76a1f6915 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -129,7 +129,10 @@ async def create_feedback( @router.get("/feedback/{id}", response_model=FeedbackModel) async def get_feedback_by_id(id: str, user=Depends(get_verified_user)): - feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id) + if user.role == "admin": + feedback = Feedbacks.get_feedback_by_id(id=id) + else: + feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id) if not feedback: raise HTTPException( @@ -143,9 +146,12 @@ async def get_feedback_by_id(id: str, user=Depends(get_verified_user)): async def update_feedback_by_id( id: str, form_data: FeedbackForm, user=Depends(get_verified_user) ): - feedback = Feedbacks.update_feedback_by_id_and_user_id( - id=id, user_id=user.id, form_data=form_data - ) + if user.role == "admin": + feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data) + else: + feedback = Feedbacks.update_feedback_by_id_and_user_id( + id=id, user_id=user.id, form_data=form_data + ) if not feedback: raise HTTPException( diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 111d3e4d3d..e419989e46 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -244,11 +244,11 @@ async def delete_folder_by_id( folder = Folders.get_folder_by_id_and_user_id(id, user.id) if folder: try: - result = Folders.delete_folder_by_id_and_user_id(id, user.id) - if result: - return result - else: - raise Exception("Error deleting folder") + folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id) + for folder_id in folder_ids: + Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id) + + return True except Exception as e: log.exception(e) log.error(f"Error deleting folder: {id}") diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index 96d8215fb3..b5beb96cf0 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -131,15 +131,29 @@ async def load_function_from_url( ############################ -class SyncFunctionsForm(FunctionForm): +class SyncFunctionsForm(BaseModel): functions: list[FunctionModel] = [] -@router.post("/sync", response_model=Optional[FunctionModel]) +@router.post("/sync", response_model=list[FunctionModel]) async def sync_functions( request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user) ): - return Functions.sync_functions(user.id, form_data.functions) + try: + for function in form_data.functions: + function.content = replace_imports(function.content) + function_module, function_type, frontmatter = load_function_module_by_id( + function.id, + content=function.content, + ) + + return Functions.sync_functions(user.id, form_data.functions) + except Exception as e: + log.exception(f"Failed to load a function: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e), + ) ############################ diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index e6e55f4d38..69198816b3 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -25,6 +25,7 @@ from open_webui.utils.access_control import has_access, has_permission from open_webui.env import SRC_LOG_LEVELS +from open_webui.config import ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS from open_webui.models.models import Models, ModelForm @@ -42,7 +43,7 @@ router = APIRouter() async def get_knowledge(user=Depends(get_verified_user)): knowledge_bases = [] - if user.role == "admin": + if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS: knowledge_bases = Knowledges.get_knowledge_bases() else: knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read") @@ -90,7 +91,7 @@ async def get_knowledge(user=Depends(get_verified_user)): async def get_knowledge_list(user=Depends(get_verified_user)): knowledge_bases = [] - if user.role == "admin": + if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS: knowledge_bases = Knowledges.get_knowledge_bases() else: knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "write") diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 333e9ecc6a..11b3d0c96c 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -82,6 +82,10 @@ class QueryMemoryForm(BaseModel): async def query_memory( request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user) ): + memories = Memories.get_memories_by_user_id(user.id) + if not memories: + raise HTTPException(status_code=404, detail="No memories found for user") + results = VECTOR_DB_CLIENT.search( collection_name=f"user-memory-{user.id}", vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)], diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 0cf3308f19..3d5f6ccf96 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -7,13 +7,15 @@ from open_webui.models.models import ( ModelUserResponse, Models, ) + +from pydantic import BaseModel from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission - +from open_webui.config import ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS router = APIRouter() @@ -25,7 +27,7 @@ router = APIRouter() @router.get("/", response_model=list[ModelUserResponse]) async def get_models(id: Optional[str] = None, user=Depends(get_verified_user)): - if user.role == "admin": + if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS: return Models.get_models() else: return Models.get_models_by_user_id(user.id) @@ -78,6 +80,32 @@ async def create_new_model( ) +############################ +# ExportModels +############################ + + +@router.get("/export", response_model=list[ModelModel]) +async def export_models(user=Depends(get_admin_user)): + return Models.get_models() + + +############################ +# SyncModels +############################ + + +class SyncModelsForm(BaseModel): + models: list[ModelModel] = [] + + +@router.post("/sync", response_model=list[ModelModel]) +async def sync_models( + request: Request, form_data: SyncModelsForm, user=Depends(get_admin_user) +): + return Models.sync_models(user.id, form_data.models) + + ########################### # GetModelById ########################### @@ -102,7 +130,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)): ############################ -# ToggelModelById +# ToggleModelById ############################ diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 5b54796a70..c8a3aebdd0 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -2,17 +2,20 @@ import asyncio import hashlib import json import logging -from pathlib import Path -from typing import Literal, Optional, overload +from typing import Optional import aiohttp from aiocache import cached import requests from urllib.parse import quote -from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, StreamingResponse +from fastapi import Depends, HTTPException, Request, APIRouter +from fastapi.responses import ( + FileResponse, + StreamingResponse, + JSONResponse, + PlainTextResponse, +) from pydantic import BaseModel from starlette.background import BackgroundTask @@ -31,7 +34,7 @@ from open_webui.env import ( from open_webui.models.users import UserModel from open_webui.constants import ERROR_MESSAGES -from open_webui.env import ENV, SRC_LOG_LEVELS +from open_webui.env import SRC_LOG_LEVELS from open_webui.utils.payload import ( @@ -95,12 +98,12 @@ async def cleanup_response( await session.close() -def openai_o_series_handler(payload): +def openai_reasoning_model_handler(payload): """ - Handle "o" series specific parameters + Handle reasoning model specific parameters """ if "max_tokens" in payload: - # Convert "max_tokens" to "max_completion_tokens" for all o-series models + # Convert "max_tokens" to "max_completion_tokens" for all reasoning models payload["max_completion_tokens"] = payload["max_tokens"] del payload["max_tokens"] @@ -362,7 +365,9 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: response if isinstance(response, list) else response.get("data", []) ): if prefix_id: - model["id"] = f"{prefix_id}.{model['id']}" + model["id"] = ( + f"{prefix_id}.{model.get('id', model.get('name', ''))}" + ) if tags: model["tags"] = tags @@ -593,15 +598,21 @@ async def verify_connection( headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: - if r.status != 200: - # Extract response error details if available - error_detail = f"HTTP Error: {r.status}" - res = await r.json() - if "error" in res: - error_detail = f"External Error: {res['error']}" - raise Exception(error_detail) + try: + response_data = await r.json() + except Exception: + response_data = await r.text() + + if r.status != 200: + if isinstance(response_data, (dict, list)): + return JSONResponse( + status_code=r.status, content=response_data + ) + else: + return PlainTextResponse( + status_code=r.status, content=response_data + ) - response_data = await r.json() return response_data else: headers["Authorization"] = f"Bearer {key}" @@ -611,15 +622,21 @@ async def verify_connection( headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: - if r.status != 200: - # Extract response error details if available - error_detail = f"HTTP Error: {r.status}" - res = await r.json() - if "error" in res: - error_detail = f"External Error: {res['error']}" - raise Exception(error_detail) + try: + response_data = await r.json() + except Exception: + response_data = await r.text() + + if r.status != 200: + if isinstance(response_data, (dict, list)): + return JSONResponse( + status_code=r.status, content=response_data + ) + else: + return PlainTextResponse( + status_code=r.status, content=response_data + ) - response_data = await r.json() return response_data except aiohttp.ClientError as e: @@ -630,8 +647,9 @@ async def verify_connection( ) except Exception as e: log.exception(f"Unexpected error: {e}") - error_detail = f"Unexpected error: {str(e)}" - raise HTTPException(status_code=500, detail=error_detail) + raise HTTPException( + status_code=500, detail="Open WebUI: Server Connection Error" + ) def get_azure_allowed_params(api_version: str) -> set[str]: @@ -787,10 +805,12 @@ async def generate_chat_completion( url = request.app.state.config.OPENAI_API_BASE_URLS[idx] key = request.app.state.config.OPENAI_API_KEYS[idx] - # Check if model is from "o" series - is_o_series = payload["model"].lower().startswith(("o1", "o3", "o4")) - if is_o_series: - payload = openai_o_series_handler(payload) + # Check if model is a reasoning model that needs special handling + is_reasoning_model = ( + payload["model"].lower().startswith(("o1", "o3", "o4", "gpt-5")) + ) + if is_reasoning_model: + payload = openai_reasoning_model_handler(payload) elif "api.openai.com" not in url: # Remove "max_completion_tokens" from the payload for backward compatibility if "max_completion_tokens" in payload: @@ -881,21 +901,19 @@ async def generate_chat_completion( log.error(e) response = await r.text() - r.raise_for_status() + if r.status >= 400: + if isinstance(response, (dict, list)): + return JSONResponse(status_code=r.status, content=response) + else: + return PlainTextResponse(status_code=r.status, content=response) + return response except Exception as e: log.exception(e) - detail = None - if isinstance(response, dict): - if "error" in response: - detail = f"{response['error']['message'] if 'message' in response['error'] else response['error']}" - elif isinstance(response, str): - detail = response - raise HTTPException( status_code=r.status if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail="Open WebUI: Server Connection Error", ) finally: if not streaming: @@ -949,7 +967,7 @@ async def embeddings(request: Request, form_data: dict, user): ), }, ) - r.raise_for_status() + if "text/event-stream" in r.headers.get("Content-Type", ""): streaming = True return StreamingResponse( @@ -961,21 +979,25 @@ async def embeddings(request: Request, form_data: dict, user): ), ) else: - response_data = await r.json() + try: + response_data = await r.json() + except Exception: + response_data = await r.text() + + if r.status >= 400: + if isinstance(response_data, (dict, list)): + return JSONResponse(status_code=r.status, content=response_data) + else: + return PlainTextResponse( + status_code=r.status, content=response_data + ) + return response_data except Exception as e: log.exception(e) - detail = None - if r is not None: - try: - res = await r.json() - if "error" in res: - detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}" - except Exception: - detail = f"External: {e}" raise HTTPException( status_code=r.status if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail="Open WebUI: Server Connection Error", ) finally: if not streaming: @@ -1041,7 +1063,6 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) - r.raise_for_status() # Check if response is SSE if "text/event-stream" in r.headers.get("Content-Type", ""): @@ -1055,24 +1076,26 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): ), ) else: - response_data = await r.json() + try: + response_data = await r.json() + except Exception: + response_data = await r.text() + + if r.status >= 400: + if isinstance(response_data, (dict, list)): + return JSONResponse(status_code=r.status, content=response_data) + else: + return PlainTextResponse( + status_code=r.status, content=response_data + ) + return response_data except Exception as e: log.exception(e) - - detail = None - if r is not None: - try: - res = await r.json() - log.error(res) - if "error" in res: - detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}" - except Exception: - detail = f"External: {e}" raise HTTPException( status_code=r.status if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail="Open WebUI: Server Connection Error", ) finally: if not streaming: diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 9fb946c6e7..afc00951fd 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -1,4 +1,5 @@ from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status, Request from open_webui.models.prompts import ( PromptForm, @@ -7,9 +8,9 @@ from open_webui.models.prompts import ( Prompts, ) from open_webui.constants import ERROR_MESSAGES -from fastapi import APIRouter, Depends, HTTPException, status, Request from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission +from open_webui.config import ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS router = APIRouter() @@ -20,7 +21,7 @@ router = APIRouter() @router.get("/", response_model=list[PromptModel]) async def get_prompts(user=Depends(get_verified_user)): - if user.role == "admin": + if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS: prompts = Prompts.get_prompts() else: prompts = Prompts.get_prompts_by_user_id(user.id, "read") @@ -30,7 +31,7 @@ async def get_prompts(user=Depends(get_verified_user)): @router.get("/list", response_model=list[PromptUserResponse]) async def get_prompt_list(user=Depends(get_verified_user)): - if user.role == "admin": + if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS: prompts = Prompts.get_prompts() else: prompts = Prompts.get_prompts_by_user_id(user.id, "write") diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index fac5706f03..c02b48e487 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -401,12 +401,14 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE, "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES, "DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY, - "DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS, + "DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL, + "DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG, "DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE, "DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR, "DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE, "DATALAB_MARKER_STRIP_EXISTING_OCR": request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION": request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + "DATALAB_MARKER_FORMAT_LINES": request.app.state.config.DATALAB_MARKER_FORMAT_LINES, "DATALAB_MARKER_USE_LLM": request.app.state.config.DATALAB_MARKER_USE_LLM, "DATALAB_MARKER_OUTPUT_FORMAT": request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, "EXTERNAL_DOCUMENT_LOADER_URL": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, @@ -566,12 +568,14 @@ class ConfigForm(BaseModel): CONTENT_EXTRACTION_ENGINE: Optional[str] = None PDF_EXTRACT_IMAGES: Optional[bool] = None DATALAB_MARKER_API_KEY: Optional[str] = None - DATALAB_MARKER_LANGS: Optional[str] = None + DATALAB_MARKER_API_BASE_URL: Optional[str] = None + DATALAB_MARKER_ADDITIONAL_CONFIG: Optional[str] = None DATALAB_MARKER_SKIP_CACHE: Optional[bool] = None DATALAB_MARKER_FORCE_OCR: Optional[bool] = None DATALAB_MARKER_PAGINATE: Optional[bool] = None DATALAB_MARKER_STRIP_EXISTING_OCR: Optional[bool] = None DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION: Optional[bool] = None + DATALAB_MARKER_FORMAT_LINES: Optional[bool] = None DATALAB_MARKER_USE_LLM: Optional[bool] = None DATALAB_MARKER_OUTPUT_FORMAT: Optional[str] = None EXTERNAL_DOCUMENT_LOADER_URL: Optional[str] = None @@ -683,10 +687,15 @@ async def update_rag_config( if form_data.DATALAB_MARKER_API_KEY is not None else request.app.state.config.DATALAB_MARKER_API_KEY ) - request.app.state.config.DATALAB_MARKER_LANGS = ( - form_data.DATALAB_MARKER_LANGS - if form_data.DATALAB_MARKER_LANGS is not None - else request.app.state.config.DATALAB_MARKER_LANGS + request.app.state.config.DATALAB_MARKER_API_BASE_URL = ( + form_data.DATALAB_MARKER_API_BASE_URL + if form_data.DATALAB_MARKER_API_BASE_URL is not None + else request.app.state.config.DATALAB_MARKER_API_BASE_URL + ) + request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG = ( + form_data.DATALAB_MARKER_ADDITIONAL_CONFIG + if form_data.DATALAB_MARKER_ADDITIONAL_CONFIG is not None + else request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG ) request.app.state.config.DATALAB_MARKER_SKIP_CACHE = ( form_data.DATALAB_MARKER_SKIP_CACHE @@ -713,6 +722,11 @@ async def update_rag_config( if form_data.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION is not None else request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION ) + request.app.state.config.DATALAB_MARKER_FORMAT_LINES = ( + form_data.DATALAB_MARKER_FORMAT_LINES + if form_data.DATALAB_MARKER_FORMAT_LINES is not None + else request.app.state.config.DATALAB_MARKER_FORMAT_LINES + ) request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = ( form_data.DATALAB_MARKER_OUTPUT_FORMAT if form_data.DATALAB_MARKER_OUTPUT_FORMAT is not None @@ -1006,7 +1020,8 @@ async def update_rag_config( "CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE, "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES, "DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY, - "DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS, + "DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL, + "DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG, "DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE, "DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR, "DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE, @@ -1229,27 +1244,14 @@ def save_docs_to_vector_db( { **doc.metadata, **(metadata if metadata else {}), - "embedding_config": json.dumps( - { - "engine": request.app.state.config.RAG_EMBEDDING_ENGINE, - "model": request.app.state.config.RAG_EMBEDDING_MODEL, - } - ), + "embedding_config": { + "engine": request.app.state.config.RAG_EMBEDDING_ENGINE, + "model": request.app.state.config.RAG_EMBEDDING_MODEL, + }, } for doc in docs ] - # ChromaDB does not like datetime formats - # for meta-data so convert them to string. - for metadata in metadatas: - for key, value in metadata.items(): - if ( - isinstance(value, datetime) - or isinstance(value, list) - or isinstance(value, dict) - ): - metadata[key] = str(value) - try: if VECTOR_DB_CLIENT.has_collection(collection_name=collection_name): log.info(f"collection {collection_name} already exists") @@ -1406,12 +1408,14 @@ def process_file( loader = Loader( engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE, DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY, - DATALAB_MARKER_LANGS=request.app.state.config.DATALAB_MARKER_LANGS, + DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL, + DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG, DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE, DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR, DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE, DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES, DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM, DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, @@ -1785,7 +1789,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: request.app.state.config.SERPLY_API_KEY, query, request.app.state.config.WEB_SEARCH_RESULT_COUNT, - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + filter_list=request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No SERPLY_API_KEY found in environment variables") @@ -1961,7 +1965,7 @@ async def process_web_search( }, ) for result in search_results - if hasattr(result, "snippet") + if hasattr(result, "snippet") and result.snippet is not None ] else: loader = get_web_loader( diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py new file mode 100644 index 0000000000..de1b979c86 --- /dev/null +++ b/backend/open_webui/routers/scim.py @@ -0,0 +1,926 @@ +""" +Experimental SCIM 2.0 Implementation for Open WebUI +Provides System for Cross-domain Identity Management endpoints for users and groups + +NOTE: This is an experimental implementation and may not fully comply with SCIM 2.0 standards, and is subject to change. +""" + +import logging +import uuid +import time +from typing import Optional, List, Dict, Any +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request, Query, Header, status +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, ConfigDict + +from open_webui.models.users import Users, UserModel +from open_webui.models.groups import Groups, GroupModel +from open_webui.utils.auth import ( + get_admin_user, + get_current_user, + decode_token, + get_verified_user, +) +from open_webui.constants import ERROR_MESSAGES +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) + +router = APIRouter() + +# SCIM 2.0 Schema URIs +SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" +SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group" +SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse" +SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" + +# SCIM Resource Types +SCIM_RESOURCE_TYPE_USER = "User" +SCIM_RESOURCE_TYPE_GROUP = "Group" + + +def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None): + """Create a SCIM-compliant error response""" + error_body = { + "schemas": [SCIM_ERROR_SCHEMA], + "status": str(status_code), + "detail": detail, + } + + if scim_type: + error_body["scimType"] = scim_type + elif status_code == 404: + error_body["scimType"] = "invalidValue" + elif status_code == 409: + error_body["scimType"] = "uniqueness" + elif status_code == 400: + error_body["scimType"] = "invalidSyntax" + + return JSONResponse(status_code=status_code, content=error_body) + + +class SCIMError(BaseModel): + """SCIM Error Response""" + + schemas: List[str] = [SCIM_ERROR_SCHEMA] + status: str + scimType: Optional[str] = None + detail: Optional[str] = None + + +class SCIMMeta(BaseModel): + """SCIM Resource Metadata""" + + resourceType: str + created: str + lastModified: str + location: Optional[str] = None + version: Optional[str] = None + + +class SCIMName(BaseModel): + """SCIM User Name""" + + formatted: Optional[str] = None + familyName: Optional[str] = None + givenName: Optional[str] = None + middleName: Optional[str] = None + honorificPrefix: Optional[str] = None + honorificSuffix: Optional[str] = None + + +class SCIMEmail(BaseModel): + """SCIM Email""" + + value: str + type: Optional[str] = "work" + primary: bool = True + display: Optional[str] = None + + +class SCIMPhoto(BaseModel): + """SCIM Photo""" + + value: str + type: Optional[str] = "photo" + primary: bool = True + display: Optional[str] = None + + +class SCIMGroupMember(BaseModel): + """SCIM Group Member""" + + value: str # User ID + ref: Optional[str] = Field(None, alias="$ref") + type: Optional[str] = "User" + display: Optional[str] = None + + +class SCIMUser(BaseModel): + """SCIM User Resource""" + + model_config = ConfigDict(populate_by_name=True) + + schemas: List[str] = [SCIM_USER_SCHEMA] + id: str + externalId: Optional[str] = None + userName: str + name: Optional[SCIMName] = None + displayName: str + emails: List[SCIMEmail] + active: bool = True + photos: Optional[List[SCIMPhoto]] = None + groups: Optional[List[Dict[str, str]]] = None + meta: SCIMMeta + + +class SCIMUserCreateRequest(BaseModel): + """SCIM User Create Request""" + + model_config = ConfigDict(populate_by_name=True) + + schemas: List[str] = [SCIM_USER_SCHEMA] + externalId: Optional[str] = None + userName: str + name: Optional[SCIMName] = None + displayName: str + emails: List[SCIMEmail] + active: bool = True + password: Optional[str] = None + photos: Optional[List[SCIMPhoto]] = None + + +class SCIMUserUpdateRequest(BaseModel): + """SCIM User Update Request""" + + model_config = ConfigDict(populate_by_name=True) + + schemas: List[str] = [SCIM_USER_SCHEMA] + id: Optional[str] = None + externalId: Optional[str] = None + userName: Optional[str] = None + name: Optional[SCIMName] = None + displayName: Optional[str] = None + emails: Optional[List[SCIMEmail]] = None + active: Optional[bool] = None + photos: Optional[List[SCIMPhoto]] = None + + +class SCIMGroup(BaseModel): + """SCIM Group Resource""" + + model_config = ConfigDict(populate_by_name=True) + + schemas: List[str] = [SCIM_GROUP_SCHEMA] + id: str + displayName: str + members: Optional[List[SCIMGroupMember]] = [] + meta: SCIMMeta + + +class SCIMGroupCreateRequest(BaseModel): + """SCIM Group Create Request""" + + model_config = ConfigDict(populate_by_name=True) + + schemas: List[str] = [SCIM_GROUP_SCHEMA] + displayName: str + members: Optional[List[SCIMGroupMember]] = [] + + +class SCIMGroupUpdateRequest(BaseModel): + """SCIM Group Update Request""" + + model_config = ConfigDict(populate_by_name=True) + + schemas: List[str] = [SCIM_GROUP_SCHEMA] + displayName: Optional[str] = None + members: Optional[List[SCIMGroupMember]] = None + + +class SCIMListResponse(BaseModel): + """SCIM List Response""" + + schemas: List[str] = [SCIM_LIST_RESPONSE_SCHEMA] + totalResults: int + itemsPerPage: int + startIndex: int + Resources: List[Any] + + +class SCIMPatchOperation(BaseModel): + """SCIM Patch Operation""" + + op: str # "add", "replace", "remove" + path: Optional[str] = None + value: Optional[Any] = None + + +class SCIMPatchRequest(BaseModel): + """SCIM Patch Request""" + + schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] + Operations: List[SCIMPatchOperation] + + +def get_scim_auth( + request: Request, authorization: Optional[str] = Header(None) +) -> bool: + """ + Verify SCIM authentication + Checks for SCIM-specific bearer token configured in the system + """ + if not authorization: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authorization header required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + parts = authorization.split() + if len(parts) != 2: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authorization format. Expected: Bearer ", + ) + + scheme, token = parts + if scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication scheme", + ) + + # Check if SCIM is enabled + scim_enabled = getattr(request.app.state, "SCIM_ENABLED", False) + log.info( + f"SCIM auth check - raw SCIM_ENABLED: {scim_enabled}, type: {type(scim_enabled)}" + ) + # Handle both PersistentConfig and direct value + if hasattr(scim_enabled, "value"): + scim_enabled = scim_enabled.value + log.info(f"SCIM enabled status after conversion: {scim_enabled}") + if not scim_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="SCIM is not enabled", + ) + + # Verify the SCIM token + scim_token = getattr(request.app.state, "SCIM_TOKEN", None) + # Handle both PersistentConfig and direct value + if hasattr(scim_token, "value"): + scim_token = scim_token.value + log.debug(f"SCIM token configured: {bool(scim_token)}") + if not scim_token or token != scim_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid SCIM token", + ) + + return True + except HTTPException: + # Re-raise HTTP exceptions as-is + raise + except Exception as e: + log.error(f"SCIM authentication error: {e}") + import traceback + + log.error(f"Traceback: {traceback.format_exc()}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication failed", + ) + + +def user_to_scim(user: UserModel, request: Request) -> SCIMUser: + """Convert internal User model to SCIM User""" + # Parse display name into name components + name_parts = user.name.split(" ", 1) if user.name else ["", ""] + given_name = name_parts[0] if name_parts else "" + family_name = name_parts[1] if len(name_parts) > 1 else "" + + # Get user's groups + user_groups = Groups.get_groups_by_member_id(user.id) + groups = [ + { + "value": group.id, + "display": group.name, + "$ref": f"{request.base_url}api/v1/scim/v2/Groups/{group.id}", + "type": "direct", + } + for group in user_groups + ] + + return SCIMUser( + id=user.id, + userName=user.email, + name=SCIMName( + formatted=user.name, + givenName=given_name, + familyName=family_name, + ), + displayName=user.name, + emails=[SCIMEmail(value=user.email)], + active=user.role != "pending", + photos=( + [SCIMPhoto(value=user.profile_image_url)] + if user.profile_image_url + else None + ), + groups=groups if groups else None, + meta=SCIMMeta( + resourceType=SCIM_RESOURCE_TYPE_USER, + created=datetime.fromtimestamp( + user.created_at, tz=timezone.utc + ).isoformat(), + lastModified=datetime.fromtimestamp( + user.updated_at, tz=timezone.utc + ).isoformat(), + location=f"{request.base_url}api/v1/scim/v2/Users/{user.id}", + ), + ) + + +def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup: + """Convert internal Group model to SCIM Group""" + members = [] + for user_id in group.user_ids: + user = Users.get_user_by_id(user_id) + if user: + members.append( + SCIMGroupMember( + value=user.id, + ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}", + display=user.name, + ) + ) + + return SCIMGroup( + id=group.id, + displayName=group.name, + members=members, + meta=SCIMMeta( + resourceType=SCIM_RESOURCE_TYPE_GROUP, + created=datetime.fromtimestamp( + group.created_at, tz=timezone.utc + ).isoformat(), + lastModified=datetime.fromtimestamp( + group.updated_at, tz=timezone.utc + ).isoformat(), + location=f"{request.base_url}api/v1/scim/v2/Groups/{group.id}", + ), + ) + + +# SCIM Service Provider Config +@router.get("/ServiceProviderConfig") +async def get_service_provider_config(): + """Get SCIM Service Provider Configuration""" + return { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "patch": {"supported": True}, + "bulk": {"supported": False, "maxOperations": 1000, "maxPayloadSize": 1048576}, + "filter": {"supported": True, "maxResults": 200}, + "changePassword": {"supported": False}, + "sort": {"supported": False}, + "etag": {"supported": False}, + "authenticationSchemes": [ + { + "type": "oauthbearertoken", + "name": "OAuth Bearer Token", + "description": "Authentication using OAuth 2.0 Bearer Token", + } + ], + } + + +# SCIM Resource Types +@router.get("/ResourceTypes") +async def get_resource_types(request: Request): + """Get SCIM Resource Types""" + return [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "id": "User", + "name": "User", + "endpoint": "/Users", + "schema": SCIM_USER_SCHEMA, + "meta": { + "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/User", + "resourceType": "ResourceType", + }, + }, + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "id": "Group", + "name": "Group", + "endpoint": "/Groups", + "schema": SCIM_GROUP_SCHEMA, + "meta": { + "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/Group", + "resourceType": "ResourceType", + }, + }, + ] + + +# SCIM Schemas +@router.get("/Schemas") +async def get_schemas(): + """Get SCIM Schemas""" + return [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], + "id": SCIM_USER_SCHEMA, + "name": "User", + "description": "User Account", + "attributes": [ + { + "name": "userName", + "type": "string", + "required": True, + "uniqueness": "server", + }, + {"name": "displayName", "type": "string", "required": True}, + { + "name": "emails", + "type": "complex", + "multiValued": True, + "required": True, + }, + {"name": "active", "type": "boolean", "required": False}, + ], + }, + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], + "id": SCIM_GROUP_SCHEMA, + "name": "Group", + "description": "Group", + "attributes": [ + {"name": "displayName", "type": "string", "required": True}, + { + "name": "members", + "type": "complex", + "multiValued": True, + "required": False, + }, + ], + }, + ] + + +# Users endpoints +@router.get("/Users", response_model=SCIMListResponse) +async def get_users( + request: Request, + startIndex: int = Query(1, ge=1), + count: int = Query(20, ge=1, le=100), + filter: Optional[str] = None, + _: bool = Depends(get_scim_auth), +): + """List SCIM Users""" + skip = startIndex - 1 + limit = count + + # Get users from database + if filter: + # Simple filter parsing - supports userName eq "email" + # In production, you'd want a more robust filter parser + if "userName eq" in filter: + email = filter.split('"')[1] + user = Users.get_user_by_email(email) + users_list = [user] if user else [] + total = 1 if user else 0 + else: + response = Users.get_users(skip=skip, limit=limit) + users_list = response["users"] + total = response["total"] + else: + response = Users.get_users(skip=skip, limit=limit) + users_list = response["users"] + total = response["total"] + + # Convert to SCIM format + scim_users = [user_to_scim(user, request) for user in users_list] + + return SCIMListResponse( + totalResults=total, + itemsPerPage=len(scim_users), + startIndex=startIndex, + Resources=scim_users, + ) + + +@router.get("/Users/{user_id}", response_model=SCIMUser) +async def get_user( + user_id: str, + request: Request, + _: bool = Depends(get_scim_auth), +): + """Get SCIM User by ID""" + user = Users.get_user_by_id(user_id) + if not user: + return scim_error( + status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found" + ) + + return user_to_scim(user, request) + + +@router.post("/Users", response_model=SCIMUser, status_code=status.HTTP_201_CREATED) +async def create_user( + request: Request, + user_data: SCIMUserCreateRequest, + _: bool = Depends(get_scim_auth), +): + """Create SCIM User""" + # Check if user already exists + existing_user = Users.get_user_by_email(user_data.userName) + if existing_user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"User with email {user_data.userName} already exists", + ) + + # Create user + user_id = str(uuid.uuid4()) + email = user_data.emails[0].value if user_data.emails else user_data.userName + + # Parse name if provided + name = user_data.displayName + if user_data.name: + if user_data.name.formatted: + name = user_data.name.formatted + elif user_data.name.givenName or user_data.name.familyName: + name = f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip() + + # Get profile image if provided + profile_image = "/user.png" + if user_data.photos and len(user_data.photos) > 0: + profile_image = user_data.photos[0].value + + # Create user + new_user = Users.insert_new_user( + id=user_id, + name=name, + email=email, + profile_image_url=profile_image, + role="user" if user_data.active else "pending", + ) + + if not new_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create user", + ) + + return user_to_scim(new_user, request) + + +@router.put("/Users/{user_id}", response_model=SCIMUser) +async def update_user( + user_id: str, + request: Request, + user_data: SCIMUserUpdateRequest, + _: bool = Depends(get_scim_auth), +): + """Update SCIM User (full update)""" + user = Users.get_user_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User {user_id} not found", + ) + + # Build update dict + update_data = {} + + if user_data.userName: + update_data["email"] = user_data.userName + + if user_data.displayName: + update_data["name"] = user_data.displayName + elif user_data.name: + if user_data.name.formatted: + update_data["name"] = user_data.name.formatted + elif user_data.name.givenName or user_data.name.familyName: + update_data["name"] = ( + f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip() + ) + + if user_data.emails and len(user_data.emails) > 0: + update_data["email"] = user_data.emails[0].value + + if user_data.active is not None: + update_data["role"] = "user" if user_data.active else "pending" + + if user_data.photos and len(user_data.photos) > 0: + update_data["profile_image_url"] = user_data.photos[0].value + + # Update user + updated_user = Users.update_user_by_id(user_id, update_data) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update user", + ) + + return user_to_scim(updated_user, request) + + +@router.patch("/Users/{user_id}", response_model=SCIMUser) +async def patch_user( + user_id: str, + request: Request, + patch_data: SCIMPatchRequest, + _: bool = Depends(get_scim_auth), +): + """Update SCIM User (partial update)""" + user = Users.get_user_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User {user_id} not found", + ) + + update_data = {} + + for operation in patch_data.Operations: + op = operation.op.lower() + path = operation.path + value = operation.value + + if op == "replace": + if path == "active": + update_data["role"] = "user" if value else "pending" + elif path == "userName": + update_data["email"] = value + elif path == "displayName": + update_data["name"] = value + elif path == "emails[primary eq true].value": + update_data["email"] = value + elif path == "name.formatted": + update_data["name"] = value + + # Update user + if update_data: + updated_user = Users.update_user_by_id(user_id, update_data) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update user", + ) + else: + updated_user = user + + return user_to_scim(updated_user, request) + + +@router.delete("/Users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: str, + request: Request, + _: bool = Depends(get_scim_auth), +): + """Delete SCIM User""" + user = Users.get_user_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User {user_id} not found", + ) + + success = Users.delete_user_by_id(user_id) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete user", + ) + + return None + + +# Groups endpoints +@router.get("/Groups", response_model=SCIMListResponse) +async def get_groups( + request: Request, + startIndex: int = Query(1, ge=1), + count: int = Query(20, ge=1, le=100), + filter: Optional[str] = None, + _: bool = Depends(get_scim_auth), +): + """List SCIM Groups""" + # Get all groups + groups_list = Groups.get_groups() + + # Apply pagination + total = len(groups_list) + start = startIndex - 1 + end = start + count + paginated_groups = groups_list[start:end] + + # Convert to SCIM format + scim_groups = [group_to_scim(group, request) for group in paginated_groups] + + return SCIMListResponse( + totalResults=total, + itemsPerPage=len(scim_groups), + startIndex=startIndex, + Resources=scim_groups, + ) + + +@router.get("/Groups/{group_id}", response_model=SCIMGroup) +async def get_group( + group_id: str, + request: Request, + _: bool = Depends(get_scim_auth), +): + """Get SCIM Group by ID""" + group = Groups.get_group_by_id(group_id) + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Group {group_id} not found", + ) + + return group_to_scim(group, request) + + +@router.post("/Groups", response_model=SCIMGroup, status_code=status.HTTP_201_CREATED) +async def create_group( + request: Request, + group_data: SCIMGroupCreateRequest, + _: bool = Depends(get_scim_auth), +): + """Create SCIM Group""" + # Extract member IDs + member_ids = [] + if group_data.members: + for member in group_data.members: + member_ids.append(member.value) + + # Create group + from open_webui.models.groups import GroupForm + + form = GroupForm( + name=group_data.displayName, + description="", + ) + + # Need to get the creating user's ID - we'll use the first admin + admin_user = Users.get_super_admin_user() + if not admin_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="No admin user found", + ) + + new_group = Groups.insert_new_group(admin_user.id, form) + if not new_group: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create group", + ) + + # Add members if provided + if member_ids: + from open_webui.models.groups import GroupUpdateForm + + update_form = GroupUpdateForm( + name=new_group.name, + description=new_group.description, + user_ids=member_ids, + ) + Groups.update_group_by_id(new_group.id, update_form) + new_group = Groups.get_group_by_id(new_group.id) + + return group_to_scim(new_group, request) + + +@router.put("/Groups/{group_id}", response_model=SCIMGroup) +async def update_group( + group_id: str, + request: Request, + group_data: SCIMGroupUpdateRequest, + _: bool = Depends(get_scim_auth), +): + """Update SCIM Group (full update)""" + group = Groups.get_group_by_id(group_id) + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Group {group_id} not found", + ) + + # Build update form + from open_webui.models.groups import GroupUpdateForm + + update_form = GroupUpdateForm( + name=group_data.displayName if group_data.displayName else group.name, + description=group.description, + ) + + # Handle members if provided + if group_data.members is not None: + member_ids = [member.value for member in group_data.members] + update_form.user_ids = member_ids + + # Update group + updated_group = Groups.update_group_by_id(group_id, update_form) + if not updated_group: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update group", + ) + + return group_to_scim(updated_group, request) + + +@router.patch("/Groups/{group_id}", response_model=SCIMGroup) +async def patch_group( + group_id: str, + request: Request, + patch_data: SCIMPatchRequest, + _: bool = Depends(get_scim_auth), +): + """Update SCIM Group (partial update)""" + group = Groups.get_group_by_id(group_id) + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Group {group_id} not found", + ) + + from open_webui.models.groups import GroupUpdateForm + + update_form = GroupUpdateForm( + name=group.name, + description=group.description, + user_ids=group.user_ids.copy() if group.user_ids else [], + ) + + for operation in patch_data.Operations: + op = operation.op.lower() + path = operation.path + value = operation.value + + if op == "replace": + if path == "displayName": + update_form.name = value + elif path == "members": + # Replace all members + update_form.user_ids = [member["value"] for member in value] + elif op == "add": + if path == "members": + # Add members + if isinstance(value, list): + for member in value: + if isinstance(member, dict) and "value" in member: + if member["value"] not in update_form.user_ids: + update_form.user_ids.append(member["value"]) + elif op == "remove": + if path and path.startswith("members[value eq"): + # Remove specific member + member_id = path.split('"')[1] + if member_id in update_form.user_ids: + update_form.user_ids.remove(member_id) + + # Update group + updated_group = Groups.update_group_by_id(group_id, update_form) + if not updated_group: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update group", + ) + + return group_to_scim(updated_group, request) + + +@router.delete("/Groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_group( + group_id: str, + request: Request, + _: bool = Depends(get_scim_auth), +): + """Delete SCIM Group""" + group = Groups.get_group_by_id(group_id) + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Group {group_id} not found", + ) + + success = Groups.delete_group_by_id(group_id) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete group", + ) + + return None diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 41415bff04..3c3e06a985 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -5,6 +5,8 @@ import time import re import aiohttp from pydantic import BaseModel, HttpUrl +from fastapi import APIRouter, Depends, HTTPException, Request, status + from open_webui.models.tools import ( ToolForm, @@ -14,16 +16,15 @@ from open_webui.models.tools import ( Tools, ) from open_webui.utils.plugin import load_tool_module_by_id, replace_imports -from open_webui.config import CACHE_DIR -from open_webui.constants import ERROR_MESSAGES -from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.utils.tools import get_tool_specs from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission -from open_webui.env import SRC_LOG_LEVELS - from open_webui.utils.tools import get_tool_servers_data +from open_webui.env import SRC_LOG_LEVELS +from open_webui.config import CACHE_DIR, ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS +from open_webui.constants import ERROR_MESSAGES + log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MAIN"]) @@ -74,15 +75,17 @@ async def get_tools(request: Request, user=Depends(get_verified_user)): ) ) - if user.role != "admin": + if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS: + # Admin can see all tools + return tools + else: tools = [ tool for tool in tools if tool.user_id == user.id or has_access(user.id, "read", tool.access_control) ] - - return tools + return tools ############################ @@ -92,7 +95,7 @@ async def get_tools(request: Request, user=Depends(get_verified_user)): @router.get("/list", response_model=list[ToolUserResponse]) async def get_tool_list(user=Depends(get_verified_user)): - if user.role == "admin": + if user.role == "admin" and ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS: tools = Tools.get_tools() else: tools = Tools.get_tools_by_user_id(user.id, "write") diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index d094047732..4bb6956f29 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -1,5 +1,13 @@ import logging from typing import Optional +import base64 +import io + + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import Response, StreamingResponse, FileResponse +from pydantic import BaseModel + from open_webui.models.auths import Auths from open_webui.models.groups import Groups @@ -21,9 +29,8 @@ from open_webui.socket.main import ( get_user_active_status, ) from open_webui.constants import ERROR_MESSAGES -from open_webui.env import SRC_LOG_LEVELS -from fastapi import APIRouter, Depends, HTTPException, Request, status -from pydantic import BaseModel +from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR + from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user from open_webui.utils.access_control import get_permissions, has_permission @@ -134,7 +141,9 @@ class SharingPermissions(BaseModel): class ChatPermissions(BaseModel): controls: bool = True + valves: bool = True system_prompt: bool = True + params: bool = True file_upload: bool = True delete: bool = True edit: bool = True @@ -327,6 +336,43 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): ) +############################ +# GetUserProfileImageById +############################ + + +@router.get("/{user_id}/profile/image") +async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)): + user = Users.get_user_by_id(user_id) + if user: + if user.profile_image_url: + # check if it's url or base64 + if user.profile_image_url.startswith("http"): + return Response( + status_code=status.HTTP_302_FOUND, + headers={"Location": user.profile_image_url}, + ) + elif user.profile_image_url.startswith("data:image"): + try: + header, base64_data = user.profile_image_url.split(",", 1) + image_data = base64.b64decode(base64_data) + image_buffer = io.BytesIO(image_data) + + return StreamingResponse( + image_buffer, + media_type="image/png", + headers={"Content-Disposition": "inline; filename=image.png"}, + ) + except Exception as e: + pass + return FileResponse(f"{STATIC_DIR}/user.png") + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + + ############################ # GetUserActiveStatusById ############################ diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index cc78bbb98d..49323db975 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -22,9 +22,11 @@ from open_webui.env import ( ENABLE_WEBSOCKET_SUPPORT, WEBSOCKET_MANAGER, WEBSOCKET_REDIS_URL, + WEBSOCKET_REDIS_CLUSTER, WEBSOCKET_REDIS_LOCK_TIMEOUT, WEBSOCKET_SENTINEL_PORT, WEBSOCKET_SENTINEL_HOSTS, + REDIS_KEY_PREFIX, ) from open_webui.utils.auth import decode_token from open_webui.socket.utils import RedisDict, RedisLock, YdocManager @@ -85,6 +87,7 @@ if WEBSOCKET_MANAGER == "redis": redis_sentinels=get_sentinels_from_env( WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT ), + redis_cluster=WEBSOCKET_REDIS_CLUSTER, async_mode=True, ) @@ -92,19 +95,22 @@ if WEBSOCKET_MANAGER == "redis": WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT ) SESSION_POOL = RedisDict( - "open-webui:session_pool", + f"{REDIS_KEY_PREFIX}:session_pool", redis_url=WEBSOCKET_REDIS_URL, redis_sentinels=redis_sentinels, + redis_cluster=WEBSOCKET_REDIS_CLUSTER, ) USER_POOL = RedisDict( - "open-webui:user_pool", + f"{REDIS_KEY_PREFIX}:user_pool", redis_url=WEBSOCKET_REDIS_URL, redis_sentinels=redis_sentinels, + redis_cluster=WEBSOCKET_REDIS_CLUSTER, ) USAGE_POOL = RedisDict( - "open-webui:usage_pool", + f"{REDIS_KEY_PREFIX}:usage_pool", redis_url=WEBSOCKET_REDIS_URL, redis_sentinels=redis_sentinels, + redis_cluster=WEBSOCKET_REDIS_CLUSTER, ) clean_up_lock = RedisLock( @@ -112,6 +118,7 @@ if WEBSOCKET_MANAGER == "redis": lock_name="usage_cleanup_lock", timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT, redis_sentinels=redis_sentinels, + redis_cluster=WEBSOCKET_REDIS_CLUSTER, ) aquire_func = clean_up_lock.aquire_lock renew_func = clean_up_lock.renew_lock @@ -126,7 +133,7 @@ else: YDOC_MANAGER = YdocManager( redis=REDIS, - redis_key_prefix="open-webui:ydoc:documents", + redis_key_prefix=f"{REDIS_KEY_PREFIX}:ydoc:documents", ) @@ -581,7 +588,7 @@ async def yjs_document_leave(sid, data): ) if ( - YDOC_MANAGER.document_exists(document_id) + await YDOC_MANAGER.document_exists(document_id) and len(await YDOC_MANAGER.get_users(document_id)) == 0 ): log.info(f"Cleaning up document {document_id} as no users are left") diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index a422d76207..168d2fd88e 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -1,18 +1,30 @@ import json import uuid from open_webui.utils.redis import get_redis_connection +from open_webui.env import REDIS_KEY_PREFIX from typing import Optional, List, Tuple import pycrdt as Y class RedisLock: - def __init__(self, redis_url, lock_name, timeout_secs, redis_sentinels=[]): + def __init__( + self, + redis_url, + lock_name, + timeout_secs, + redis_sentinels=[], + redis_cluster=False, + ): + self.lock_name = lock_name self.lock_id = str(uuid.uuid4()) self.timeout_secs = timeout_secs self.lock_obtained = False self.redis = get_redis_connection( - redis_url, redis_sentinels, decode_responses=True + redis_url, + redis_sentinels, + redis_cluster=redis_cluster, + decode_responses=True, ) def aquire_lock(self): @@ -35,10 +47,13 @@ class RedisLock: class RedisDict: - def __init__(self, name, redis_url, redis_sentinels=[]): + def __init__(self, name, redis_url, redis_sentinels=[], redis_cluster=False): self.name = name self.redis = get_redis_connection( - redis_url, redis_sentinels, decode_responses=True + redis_url, + redis_sentinels, + redis_cluster=redis_cluster, + decode_responses=True, ) def __setitem__(self, key, value): @@ -97,7 +112,7 @@ class YdocManager: def __init__( self, redis=None, - redis_key_prefix: str = "open-webui:ydoc:documents", + redis_key_prefix: str = f"{REDIS_KEY_PREFIX}:ydoc:documents", ): self._updates = {} self._users = {} diff --git a/backend/open_webui/static/user.png b/backend/open_webui/static/user.png new file mode 100644 index 0000000000..7bdc70d159 Binary files /dev/null and b/backend/open_webui/static/user.png differ diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py index a4132d9cf6..714c532fca 100644 --- a/backend/open_webui/tasks.py +++ b/backend/open_webui/tasks.py @@ -8,7 +8,7 @@ from redis.asyncio import Redis from fastapi import Request from typing import Dict, List, Optional -from open_webui.env import SRC_LOG_LEVELS +from open_webui.env import SRC_LOG_LEVELS, REDIS_KEY_PREFIX log = logging.getLogger(__name__) @@ -19,9 +19,9 @@ tasks: Dict[str, asyncio.Task] = {} item_tasks = {} -REDIS_TASKS_KEY = "open-webui:tasks" -REDIS_ITEM_TASKS_KEY = "open-webui:tasks:item" -REDIS_PUBSUB_CHANNEL = "open-webui:tasks:commands" +REDIS_TASKS_KEY = f"{REDIS_KEY_PREFIX}:tasks" +REDIS_ITEM_TASKS_KEY = f"{REDIS_KEY_PREFIX}:tasks:item" +REDIS_PUBSUB_CHANNEL = f"{REDIS_KEY_PREFIX}:tasks:commands" async def redis_task_command_listener(app): diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 5f30738cfe..228dd3e30a 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -221,7 +221,7 @@ def get_current_user( token = request.cookies.get("token") if token is None: - raise HTTPException(status_code=403, detail="Not authenticated") + raise HTTPException(status_code=401, detail="Not authenticated") # auth by api key if token.startswith("sk-"): diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py index ff7d5c4546..540527bf82 100644 --- a/backend/open_webui/utils/logger.py +++ b/backend/open_webui/utils/logger.py @@ -5,8 +5,6 @@ from typing import TYPE_CHECKING from loguru import logger from opentelemetry import trace - - from open_webui.env import ( AUDIT_UVICORN_LOGGER_NAMES, AUDIT_LOG_FILE_ROTATION_SIZE, @@ -14,6 +12,7 @@ from open_webui.env import ( AUDIT_LOGS_FILE_PATH, GLOBAL_LOG_LEVEL, ENABLE_OTEL, + ENABLE_OTEL_LOGS, ) @@ -30,13 +29,16 @@ def stdout_format(record: "Record") -> str: Returns: str: A formatted log string intended for stdout. """ - record["extra"]["extra_json"] = json.dumps(record["extra"]) + if record["extra"]: + record["extra"]["extra_json"] = json.dumps(record["extra"]) + extra_format = " - {extra[extra_json]}" + else: + extra_format = "" return ( "{time:YYYY-MM-DD HH:mm:ss.SSS} | " "{level: <8} | " "{name}:{function}:{line} - " - "{message} - {extra[extra_json]}" - "\n{exception}" + "{message}" + extra_format + "\n{exception}" ) @@ -65,6 +67,10 @@ class InterceptHandler(logging.Handler): logger.opt(depth=depth, exception=record.exc_info).bind( **self._get_extras() ).log(level, record.getMessage()) + if ENABLE_OTEL and ENABLE_OTEL_LOGS: + from open_webui.utils.telemetry.logs import otel_handler + + otel_handler.emit(record) def _get_extras(self): if not ENABLE_OTEL: @@ -126,7 +132,6 @@ def start_logger(): format=stdout_format, filter=lambda record: "auditable" not in record["extra"], ) - if AUDIT_LOG_LEVEL != "NONE": try: logger.add( diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 7000d37863..a433ef3a0c 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -83,6 +83,7 @@ from open_webui.utils.filter import ( process_filter_functions, ) from open_webui.utils.code_interpreter import execute_code_jupyter +from open_webui.utils.payload import apply_model_system_prompt_to_body from open_webui.tasks import create_task @@ -94,6 +95,7 @@ from open_webui.config import ( from open_webui.env import ( SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL, + CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE, BYPASS_MODEL_ACCESS_CONTROL, ENABLE_REALTIME_CHAT_SAVE, ) @@ -683,6 +685,7 @@ def apply_params_to_form_data(form_data, model): open_webui_params = { "stream_response": bool, + "stream_delta_chunk_size": int, "function_calling": str, "system": str, } @@ -774,8 +777,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): if folder and folder.data: if "system_prompt" in folder.data: - form_data["messages"] = add_or_update_system_message( - folder.data["system_prompt"], form_data["messages"] + form_data = apply_model_system_prompt_to_body( + folder.data["system_prompt"], form_data, metadata, user ) if "files" in folder.data: form_data["files"] = [ @@ -929,7 +932,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): } if tools_dict: - if metadata.get("function_calling") == "native": + if metadata.get("params", {}).get("function_calling") == "native": # If the function calling is native, then call the tools function calling handler metadata["tools"] = tools_dict form_data["tools"] = [ @@ -1381,14 +1384,6 @@ async def process_chat_response( task_id = str(uuid4()) # Create a unique task ID. model_id = form_data.get("model", "") - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], - { - "model": model_id, - }, - ) - def split_content_and_whitespace(content): content_stripped = content.rstrip() original_whitespace = ( @@ -1410,13 +1405,18 @@ async def process_chat_response( for block in content_blocks: if block["type"] == "text": - content = f"{content}{block['content'].strip()}\n" + block_content = block["content"].strip() + if block_content: + content = f"{content}{block_content}\n" elif block["type"] == "tool_calls": attributes = block.get("attributes", {}) tool_calls = block.get("content", []) results = block.get("results", []) + if content and not content.endswith("\n"): + content += "\n" + if results: tool_calls_display_content = "" @@ -1439,12 +1439,12 @@ async def process_chat_response( break if tool_result: - tool_calls_display_content = f'{tool_calls_display_content}\n\nTool Executed\n\n' + tool_calls_display_content = f'{tool_calls_display_content}\nTool Executed\n\n' else: - tool_calls_display_content = f'{tool_calls_display_content}\n\nExecuting...\n' + tool_calls_display_content = f'{tool_calls_display_content}\nExecuting...\n\n' if not raw: - content = f"{content}\n{tool_calls_display_content}\n\n" + content = f"{content}{tool_calls_display_content}" else: tool_calls_display_content = "" @@ -1457,10 +1457,10 @@ async def process_chat_response( "arguments", "" ) - tool_calls_display_content = f'{tool_calls_display_content}\n\nExecuting...\n' + tool_calls_display_content = f'{tool_calls_display_content}\n\nExecuting...\n\n' if not raw: - content = f"{content}\n{tool_calls_display_content}\n\n" + content = f"{content}{tool_calls_display_content}" elif block["type"] == "reasoning": reasoning_display_content = "\n".join( @@ -1470,16 +1470,26 @@ async def process_chat_response( reasoning_duration = block.get("duration", None) + start_tag = block.get("start_tag", "") + end_tag = block.get("end_tag", "") + + if content and not content.endswith("\n"): + content += "\n" + if reasoning_duration is not None: if raw: - content = f'{content}\n{block["start_tag"]}{block["content"]}{block["end_tag"]}\n' + content = ( + f'{content}{start_tag}{block["content"]}{end_tag}\n' + ) else: - content = f'{content}\n\nThought for {reasoning_duration} seconds\n{reasoning_display_content}\n\n' + content = f'{content}\nThought for {reasoning_duration} seconds\n{reasoning_display_content}\n\n' else: if raw: - content = f'{content}\n{block["start_tag"]}{block["content"]}{block["end_tag"]}\n' + content = ( + f'{content}{start_tag}{block["content"]}{end_tag}\n' + ) else: - content = f'{content}\n\nThinking…\n{reasoning_display_content}\n\n' + content = f'{content}\nThinking…\n{reasoning_display_content}\n\n' elif block["type"] == "code_interpreter": attributes = block.get("attributes", {}) @@ -1499,26 +1509,30 @@ async def process_chat_response( # Keep content as is - either closing backticks or no backticks content = content_stripped + original_whitespace + if content and not content.endswith("\n"): + content += "\n" + if output: output = html.escape(json.dumps(output)) if raw: - content = f'{content}\n\n{block["content"]}\n\n```output\n{output}\n```\n' + content = f'{content}\n{block["content"]}\n\n```output\n{output}\n```\n' else: - content = f'{content}\n\nAnalyzed\n```{lang}\n{block["content"]}\n```\n\n' + content = f'{content}\nAnalyzed\n```{lang}\n{block["content"]}\n```\n\n' else: if raw: - content = f'{content}\n\n{block["content"]}\n\n' + content = f'{content}\n{block["content"]}\n\n' else: - content = f'{content}\n\nAnalyzing...\n```{lang}\n{block["content"]}\n```\n\n' + content = f'{content}\nAnalyzing...\n```{lang}\n{block["content"]}\n```\n\n' else: block_content = str(block["content"]).strip() - content = f"{content}{block['type']}: {block_content}\n" + if block_content: + content = f"{content}{block['type']}: {block_content}\n" return content.strip() - def convert_content_blocks_to_messages(content_blocks): + def convert_content_blocks_to_messages(content_blocks, raw=False): messages = [] temp_blocks = [] @@ -1527,7 +1541,7 @@ async def process_chat_response( messages.append( { "role": "assistant", - "content": serialize_content_blocks(temp_blocks), + "content": serialize_content_blocks(temp_blocks, raw), "tool_calls": block.get("content"), } ) @@ -1547,7 +1561,7 @@ async def process_chat_response( temp_blocks.append(block) if temp_blocks: - content = serialize_content_blocks(temp_blocks) + content = serialize_content_blocks(temp_blocks, raw) if content: messages.append( { @@ -1804,6 +1818,15 @@ async def process_chat_response( response_tool_calls = [] + delta_count = 0 + delta_chunk_size = max( + CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE, + int( + metadata.get("params", {}).get("stream_delta_chunk_size") + or 1 + ), + ) + async for line in response.body_iterator: line = line.decode("utf-8") if isinstance(line, bytes) else line data = line @@ -1943,8 +1966,8 @@ async def process_chat_response( ): reasoning_block = { "type": "reasoning", - "start_tag": "think", - "end_tag": "/think", + "start_tag": "", + "end_tag": "", "attributes": { "type": "reasoning_content" }, @@ -2051,12 +2074,23 @@ async def process_chat_response( ), } - await event_emitter( - { - "type": "chat:completion", - "data": data, - } - ) + if delta: + delta_count += 1 + if delta_count >= delta_chunk_size: + await event_emitter( + { + "type": "chat:completion", + "data": data, + } + ) + delta_count = 0 + else: + await event_emitter( + { + "type": "chat:completion", + "data": data, + } + ) except Exception as e: done = "data: [DONE]" in line if done: @@ -2083,6 +2117,15 @@ async def process_chat_response( } ) + if content_blocks[-1]["type"] == "reasoning": + reasoning_block = content_blocks[-1] + if reasoning_block.get("ended_at") is None: + reasoning_block["ended_at"] = time.time() + reasoning_block["duration"] = int( + reasoning_block["ended_at"] + - reasoning_block["started_at"] + ) + if response_tool_calls: tool_calls.append(response_tool_calls) @@ -2095,6 +2138,7 @@ async def process_chat_response( tool_call_retries = 0 while len(tool_calls) > 0 and tool_call_retries < MAX_TOOL_CALL_RETRIES: + tool_call_retries += 1 response_tool_calls = tool_calls.pop(0) @@ -2246,7 +2290,9 @@ async def process_chat_response( "tools": form_data["tools"], "messages": [ *form_data["messages"], - *convert_content_blocks_to_messages(content_blocks), + *convert_content_blocks_to_messages( + content_blocks, True + ), ], } diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 107e2ed252..2a780209a7 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -227,7 +227,7 @@ def openai_chat_chunk_message_template( if tool_calls: template["choices"][0]["delta"]["tool_calls"] = tool_calls - if not content and not tool_calls: + if not content and not reasoning_content and not tool_calls: template["choices"][0]["finish_reason"] = "stop" if usage: diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 2be9cda92a..131c35800e 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -27,6 +27,7 @@ from open_webui.config import ( ENABLE_OAUTH_GROUP_CREATION, OAUTH_BLOCKED_GROUPS, OAUTH_ROLES_CLAIM, + OAUTH_SUB_CLAIM, OAUTH_GROUPS_CLAIM, OAUTH_EMAIL_CLAIM, OAUTH_PICTURE_CLAIM, @@ -65,6 +66,7 @@ auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMEN auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM +auth_manager_config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM auth_manager_config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM @@ -88,11 +90,12 @@ class OAuthManager: return self.oauth.create_client(provider_name) def get_user_role(self, user, user_data): - if user and Users.get_num_users() == 1: + user_count = Users.get_num_users() + if user and user_count == 1: # If the user is the only user, assign the role "admin" - actually repairs role for single user on login log.debug("Assigning the only user the admin role") return "admin" - if not user and Users.get_num_users() == 0: + if not user and user_count == 0: # If there are no users, assign the role "admin", as the first user will be an admin log.debug("Assigning the first user the admin role") return "admin" @@ -358,11 +361,18 @@ class OAuthManager: log.warning(f"OAuth callback failed, user data is missing: {token}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub")) + if auth_manager_config.OAUTH_SUB_CLAIM: + sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM) + else: + # Fallback to the default sub claim if not configured + sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub")) + if not sub: log.warning(f"OAuth callback failed, sub is missing: {user_data}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + provider_sub = f"{provider}@{sub}" + email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM email = user_data.get(email_claim, "") # We currently mandate that email addresses are provided @@ -449,8 +459,6 @@ class OAuthManager: log.debug(f"Updated profile picture for user {user.email}") if not user: - user_count = Users.get_num_users() - # If the user does not exist, check if signups are enabled if auth_manager_config.ENABLE_OAUTH_SIGNUP: # Check if an existing user with the same email already exists @@ -521,7 +529,7 @@ class OAuthManager: response.set_cookie( key="token", value=jwt_token, - httponly=True, # Ensures the cookie is not accessible via JavaScript + httponly=False, # Required for frontend access samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, ) @@ -540,6 +548,6 @@ class OAuthManager: redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url) if redirect_base_url.endswith("/"): redirect_base_url = redirect_base_url[:-1] - redirect_url = f"{redirect_base_url}/auth#token={jwt_token}" + redirect_url = f"{redirect_base_url}/auth" return RedirectResponse(url=redirect_url, headers=response.headers) diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 9b7f748359..316e61c34c 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -69,6 +69,7 @@ def remove_open_webui_params(params: dict) -> dict: """ open_webui_params = { "stream_response": bool, + "stream_delta_chunk_size": int, "function_calling": str, "system": str, } diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py index ca450028b0..c60a6fa517 100644 --- a/backend/open_webui/utils/redis.py +++ b/backend/open_webui/utils/redis.py @@ -10,6 +10,9 @@ from open_webui.env import REDIS_SENTINEL_MAX_RETRY_COUNT log = logging.getLogger(__name__) +_CONNECTION_CACHE = {} + + class SentinelRedisProxy: def __init__(self, sentinel, service, *, async_mode: bool = True, **kw): self._sentinel = sentinel @@ -93,8 +96,8 @@ class SentinelRedisProxy: def parse_redis_service_url(redis_url): parsed_url = urlparse(redis_url) - if parsed_url.scheme != "redis": - raise ValueError("Invalid Redis URL scheme. Must be 'redis'.") + if parsed_url.scheme != "redis" and parsed_url.scheme != "rediss": + raise ValueError("Invalid Redis URL scheme. Must be 'redis' or 'rediss'.") return { "username": parsed_url.username or None, @@ -106,8 +109,25 @@ def parse_redis_service_url(redis_url): def get_redis_connection( - redis_url, redis_sentinels, async_mode=False, decode_responses=True + redis_url, + redis_sentinels, + redis_cluster=False, + async_mode=False, + decode_responses=True, ): + + cache_key = ( + redis_url, + tuple(redis_sentinels) if redis_sentinels else (), + async_mode, + decode_responses, + ) + + if cache_key in _CONNECTION_CACHE: + return _CONNECTION_CACHE[cache_key] + + connection = None + if async_mode: import redis.asyncio as redis @@ -122,15 +142,19 @@ def get_redis_connection( password=redis_config["password"], decode_responses=decode_responses, ) - return SentinelRedisProxy( + connection = SentinelRedisProxy( sentinel, redis_config["service"], async_mode=async_mode, ) + elif redis_cluster: + if not redis_url: + raise ValueError("Redis URL must be provided for cluster mode.") + return redis.cluster.RedisCluster.from_url( + redis_url, decode_responses=decode_responses + ) elif redis_url: - return redis.from_url(redis_url, decode_responses=decode_responses) - else: - return None + connection = redis.from_url(redis_url, decode_responses=decode_responses) else: import redis @@ -144,15 +168,24 @@ def get_redis_connection( password=redis_config["password"], decode_responses=decode_responses, ) - return SentinelRedisProxy( + connection = SentinelRedisProxy( sentinel, redis_config["service"], async_mode=async_mode, ) + elif redis_cluster: + if not redis_url: + raise ValueError("Redis URL must be provided for cluster mode.") + return redis.cluster.RedisCluster.from_url( + redis_url, decode_responses=decode_responses + ) elif redis_url: - return redis.Redis.from_url(redis_url, decode_responses=decode_responses) - else: - return None + connection = redis.Redis.from_url( + redis_url, decode_responses=decode_responses + ) + + _CONNECTION_CACHE[cache_key] = connection + return connection def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env): diff --git a/backend/open_webui/utils/telemetry/exporters.py b/backend/open_webui/utils/telemetry/exporters.py deleted file mode 100644 index 4bf166e655..0000000000 --- a/backend/open_webui/utils/telemetry/exporters.py +++ /dev/null @@ -1,31 +0,0 @@ -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() diff --git a/backend/open_webui/utils/telemetry/logs.py b/backend/open_webui/utils/telemetry/logs.py new file mode 100644 index 0000000000..00d3e28c07 --- /dev/null +++ b/backend/open_webui/utils/telemetry/logs.py @@ -0,0 +1,53 @@ +import logging +from base64 import b64encode +from opentelemetry.sdk._logs import ( + LoggingHandler, + LoggerProvider, +) +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter +from opentelemetry.exporter.otlp.proto.http._log_exporter import ( + OTLPLogExporter as HttpOTLPLogExporter, +) +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry._logs import set_logger_provider +from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from open_webui.env import ( + OTEL_SERVICE_NAME, + OTEL_LOGS_EXPORTER_OTLP_ENDPOINT, + OTEL_LOGS_EXPORTER_OTLP_INSECURE, + OTEL_LOGS_BASIC_AUTH_USERNAME, + OTEL_LOGS_BASIC_AUTH_PASSWORD, + OTEL_LOGS_OTLP_SPAN_EXPORTER, +) + + +def setup_logging(): + headers = [] + if OTEL_LOGS_BASIC_AUTH_USERNAME and OTEL_LOGS_BASIC_AUTH_PASSWORD: + auth_string = f"{OTEL_LOGS_BASIC_AUTH_USERNAME}:{OTEL_LOGS_BASIC_AUTH_PASSWORD}" + auth_header = b64encode(auth_string.encode()).decode() + headers = [("authorization", f"Basic {auth_header}")] + resource = Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME}) + + if OTEL_LOGS_OTLP_SPAN_EXPORTER == "http": + exporter = HttpOTLPLogExporter( + endpoint=OTEL_LOGS_EXPORTER_OTLP_ENDPOINT, + headers=headers, + ) + else: + exporter = OTLPLogExporter( + endpoint=OTEL_LOGS_EXPORTER_OTLP_ENDPOINT, + insecure=OTEL_LOGS_EXPORTER_OTLP_INSECURE, + headers=headers, + ) + logger_provider = LoggerProvider(resource=resource) + set_logger_provider(logger_provider) + + logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter)) + + otel_handler = LoggingHandler(logger_provider=logger_provider) + + return otel_handler + + +otel_handler = setup_logging() diff --git a/backend/open_webui/utils/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py index f3e82c7dab..75c13ccc0a 100644 --- a/backend/open_webui/utils/telemetry/metrics.py +++ b/backend/open_webui/utils/telemetry/metrics.py @@ -19,37 +19,69 @@ from __future__ import annotations import time from typing import Dict, List, Sequence, Any +from base64 import b64encode from fastapi import FastAPI, Request from opentelemetry import metrics from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( OTLPMetricExporter, ) + +from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( + OTLPMetricExporter as OTLPHttpMetricExporter, +) from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.view import View from opentelemetry.sdk.metrics.export import ( PeriodicExportingMetricReader, ) -from opentelemetry.sdk.resources import SERVICE_NAME, Resource - -from open_webui.env import OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT +from opentelemetry.sdk.resources import Resource +from open_webui.env import ( + OTEL_SERVICE_NAME, + OTEL_METRICS_EXPORTER_OTLP_ENDPOINT, + OTEL_METRICS_BASIC_AUTH_USERNAME, + OTEL_METRICS_BASIC_AUTH_PASSWORD, + OTEL_METRICS_OTLP_SPAN_EXPORTER, + OTEL_METRICS_EXPORTER_OTLP_INSECURE, +) from open_webui.socket.main import get_active_user_ids from open_webui.models.users import Users _EXPORT_INTERVAL_MILLIS = 10_000 # 10 seconds -def _build_meter_provider() -> MeterProvider: +def _build_meter_provider(resource: Resource) -> MeterProvider: """Return a configured MeterProvider.""" + headers = [] + if OTEL_METRICS_BASIC_AUTH_USERNAME and OTEL_METRICS_BASIC_AUTH_PASSWORD: + auth_string = ( + f"{OTEL_METRICS_BASIC_AUTH_USERNAME}:{OTEL_METRICS_BASIC_AUTH_PASSWORD}" + ) + auth_header = b64encode(auth_string.encode()).decode() + headers = [("authorization", f"Basic {auth_header}")] # Periodic reader pushes metrics over OTLP/gRPC to collector - readers: List[PeriodicExportingMetricReader] = [ - PeriodicExportingMetricReader( - OTLPMetricExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT), - export_interval_millis=_EXPORT_INTERVAL_MILLIS, - ) - ] + if OTEL_METRICS_OTLP_SPAN_EXPORTER == "http": + readers: List[PeriodicExportingMetricReader] = [ + PeriodicExportingMetricReader( + OTLPHttpMetricExporter( + endpoint=OTEL_METRICS_EXPORTER_OTLP_ENDPOINT, headers=headers + ), + export_interval_millis=_EXPORT_INTERVAL_MILLIS, + ) + ] + else: + readers: List[PeriodicExportingMetricReader] = [ + PeriodicExportingMetricReader( + OTLPMetricExporter( + endpoint=OTEL_METRICS_EXPORTER_OTLP_ENDPOINT, + insecure=OTEL_METRICS_EXPORTER_OTLP_INSECURE, + headers=headers, + ), + export_interval_millis=_EXPORT_INTERVAL_MILLIS, + ) + ] # Optional view to limit cardinality: drop user-agent etc. views: List[View] = [ @@ -70,17 +102,17 @@ def _build_meter_provider() -> MeterProvider: ] provider = MeterProvider( - resource=Resource.create({SERVICE_NAME: OTEL_SERVICE_NAME}), + resource=resource, metric_readers=list(readers), views=views, ) return provider -def setup_metrics(app: FastAPI) -> None: +def setup_metrics(app: FastAPI, resource: Resource) -> None: """Attach OTel metrics middleware to *app* and initialise provider.""" - metrics.set_meter_provider(_build_meter_provider()) + metrics.set_meter_provider(_build_meter_provider(resource)) meter = metrics.get_meter(__name__) # Instruments diff --git a/backend/open_webui/utils/telemetry/setup.py b/backend/open_webui/utils/telemetry/setup.py index 8209ba4131..cd1f45ea6a 100644 --- a/backend/open_webui/utils/telemetry/setup.py +++ b/backend/open_webui/utils/telemetry/setup.py @@ -1,15 +1,16 @@ from fastapi import FastAPI from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( OTLPSpanExporter as HttpOTLPSpanExporter, ) from opentelemetry.sdk.resources import SERVICE_NAME, Resource from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor from sqlalchemy import Engine from base64 import b64encode -from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor from open_webui.utils.telemetry.instrumentors import Instrumentor from open_webui.utils.telemetry.metrics import setup_metrics from open_webui.env import ( @@ -25,11 +26,8 @@ from open_webui.env import ( def setup(app: FastAPI, db_engine: Engine): # set up trace - trace.set_tracer_provider( - TracerProvider( - resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME}) - ) - ) + resource = Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME}) + trace.set_tracer_provider(TracerProvider(resource=resource)) # Add basic auth header only if both username and password are not empty headers = [] @@ -42,7 +40,6 @@ def setup(app: FastAPI, db_engine: Engine): if OTEL_OTLP_SPAN_EXPORTER == "http": exporter = HttpOTLPSpanExporter( endpoint=OTEL_EXPORTER_OTLP_ENDPOINT, - insecure=OTEL_EXPORTER_OTLP_INSECURE, headers=headers, ) else: @@ -51,9 +48,9 @@ def setup(app: FastAPI, db_engine: Engine): insecure=OTEL_EXPORTER_OTLP_INSECURE, headers=headers, ) - trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter)) + trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(exporter)) Instrumentor(app=app, db_engine=db_engine).instrument() # set up metrics only if enabled if ENABLE_OTEL_METRICS: - setup_metrics(app) + setup_metrics(app, resource) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 02c8b3a86b..3727bb1ad9 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -377,7 +377,6 @@ def convert_openapi_to_tool_payload(openapi_spec): for method, operation in methods.items(): if operation.get("operationId"): tool = { - "type": "function", "name": operation.get("operationId"), "description": operation.get( "description", @@ -399,10 +398,16 @@ def convert_openapi_to_tool_payload(openapi_spec): description += ( f". Possible values: {', '.join(param_schema.get('enum'))}" ) - tool["parameters"]["properties"][param_name] = { + param_property = { "type": param_schema.get("type"), "description": description, } + + # Include items property for array types (required by OpenAI) + if param_schema.get("type") == "array" and "items" in param_schema: + param_property["items"] = param_schema["items"] + + tool["parameters"]["properties"][param_name] = param_property if param.get("required"): tool["parameters"]["required"].append(param_name) @@ -489,15 +494,7 @@ async def get_tool_servers_data( if server.get("config", {}).get("enable"): # Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL openapi_path = server.get("path", "openapi.json") - if "://" in openapi_path: - # If it contains "://", it's a full URL - full_url = openapi_path - else: - if not openapi_path.startswith("/"): - # Ensure the path starts with a slash - openapi_path = f"/{openapi_path}" - - full_url = f"{server.get('url')}{openapi_path}" + full_url = get_tool_server_url(server.get("url"), openapi_path) info = server.get("info", {}) @@ -528,6 +525,8 @@ async def get_tool_servers_data( openapi_data = response.get("openapi", {}) if info and isinstance(openapi_data, dict): + openapi_data["info"] = openapi_data.get("info", {}) + if "name" in info: openapi_data["info"]["title"] = info.get("name", "Tool Server") @@ -643,3 +642,16 @@ async def execute_tool_server( error = str(err) log.exception(f"API Request Error: {error}") return {"error": error} + + +def get_tool_server_url(url: Optional[str], path: str) -> str: + """ + Build the full URL for a tool server, given a base url and a path. + """ + if "://" in path: + # If it contains "://", it's a full URL + return path + if not path.startswith("/"): + # Ensure the path starts with a slash + path = f"/{path}" + return f"{url}{path}" diff --git a/backend/requirements.txt b/backend/requirements.txt index 94badb254d..793e7d5332 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,7 +9,7 @@ passlib[bcrypt]==1.7.4 cryptography requests==2.32.4 -aiohttp==3.11.11 +aiohttp==3.12.15 async-timeout aiocache aiofiles @@ -27,7 +27,7 @@ bcrypt==4.3.0 pymongo redis -boto3==1.35.53 +boto3==1.40.5 argon2-cffi==23.1.0 APScheduler==3.10.4 @@ -42,14 +42,14 @@ asgiref==3.8.1 # AI libraries openai anthropic -google-genai==1.15.0 +google-genai==1.28.0 google-generativeai==0.8.5 tiktoken langchain==0.3.26 langchain-community==0.3.26 -fake-useragent==2.1.0 +fake-useragent==2.2.0 chromadb==0.6.3 posthog==5.4.0 pymilvus==2.5.0 @@ -58,11 +58,14 @@ opensearch-py==2.8.0 playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml elasticsearch==9.0.1 pinecone==6.0.2 +oracledb==3.2.0 +av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720 transformers sentence-transformers==4.1.0 accelerate colbert-ai==0.2.21 +pyarrow==20.0.0 einops==0.8.1 @@ -74,7 +77,7 @@ docx2txt==0.8 python-pptx==1.0.2 unstructured==0.16.17 nltk==3.9.1 -Markdown==3.7 +Markdown==3.8.2 pypandoc==1.15 pandas==2.2.3 openpyxl==3.1.5 @@ -86,7 +89,7 @@ sentencepiece soundfile==0.13.1 azure-ai-documentintelligence==1.0.2 -pillow==11.2.1 +pillow==11.3.0 opencv-python-headless==4.11.0.86 rapidocr-onnxruntime==1.4.4 rank-bm25==0.2.2 @@ -96,7 +99,7 @@ onnxruntime==1.20.1 faster-whisper==1.1.1 PyJWT[crypto]==2.10.1 -authlib==1.4.1 +authlib==1.6.1 black==25.1.0 langfuse==2.44.0 @@ -133,14 +136,14 @@ firecrawl-py==1.12.0 tencentcloud-sdk-python==3.0.1336 ## Trace -opentelemetry-api==1.32.1 -opentelemetry-sdk==1.32.1 -opentelemetry-exporter-otlp==1.32.1 -opentelemetry-instrumentation==0.53b1 -opentelemetry-instrumentation-fastapi==0.53b1 -opentelemetry-instrumentation-sqlalchemy==0.53b1 -opentelemetry-instrumentation-redis==0.53b1 -opentelemetry-instrumentation-requests==0.53b1 -opentelemetry-instrumentation-logging==0.53b1 -opentelemetry-instrumentation-httpx==0.53b1 -opentelemetry-instrumentation-aiohttp-client==0.53b1 +opentelemetry-api==1.36.0 +opentelemetry-sdk==1.36.0 +opentelemetry-exporter-otlp==1.36.0 +opentelemetry-instrumentation==0.57b0 +opentelemetry-instrumentation-fastapi==0.57b0 +opentelemetry-instrumentation-sqlalchemy==0.57b0 +opentelemetry-instrumentation-redis==0.57b0 +opentelemetry-instrumentation-requests==0.57b0 +opentelemetry-instrumentation-logging==0.57b0 +opentelemetry-instrumentation-httpx==0.57b0 +opentelemetry-instrumentation-aiohttp-client==0.57b0 diff --git a/package-lock.json b/package-lock.json index 6c49690d66..fb4da368c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.6.18", + "version": "0.6.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.6.18", + "version": "0.6.19", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -15,6 +15,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@floating-ui/dom": "^1.7.2", "@huggingface/transformers": "^3.0.0", + "@joplin/turndown-plugin-gfm": "^1.0.62", "@mediapipe/tasks-vision": "^0.10.17", "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^2.0.0", @@ -29,6 +30,7 @@ "@tiptap/extension-image": "^3.0.7", "@tiptap/extension-link": "^3.0.7", "@tiptap/extension-list": "^3.0.7", + "@tiptap/extension-mention": "^3.0.9", "@tiptap/extension-table": "^3.0.7", "@tiptap/extension-typography": "^3.0.7", "@tiptap/extension-youtube": "^3.0.7", @@ -183,6 +185,22 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@azure/msal-browser": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.5.0.tgz", @@ -645,6 +663,131 @@ "node": ">=0.1.90" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@cypress/request": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.5.tgz", @@ -1184,10 +1327,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1287,10 +1431,11 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1841,6 +1986,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@joplin/turndown-plugin-gfm": { + "version": "1.0.62", + "resolved": "https://registry.npmjs.org/@joplin/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.62.tgz", + "integrity": "sha512-Ts7cZ0Y9rIRgNkPtpXYB3BVjjSP2eeWzrPnQvJgNTC+FpopSjoaYjLQvPcEj1d6JcTMegnYoZK98/WJhm02Uaw==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -2710,6 +2861,15 @@ "svelte": "^3.55.0 || ^4.0.0 || ^5.0.0" } }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", + "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, "node_modules/@sveltejs/adapter-auto": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.2.2.tgz", @@ -2747,16 +2907,17 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.20.2", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.2.tgz", - "integrity": "sha512-Dv8TOAZC9vyfcAB9TMsvUEJsRbklRTeNfcYBPaeH6KnABJ99i3CvCB2eNx8fiiliIqe+9GIchBg4RodRH5p1BQ==", + "version": "2.22.4", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.22.4.tgz", + "integrity": "sha512-BXK9hTbP8AeQIfoz6+P3uoyVYStVHc5CIKqoTSF7hXm3Q5P9BwFMdEus4jsQuhaYmXGHzukcGlxe2QrsE8BJfQ==", "license": "MIT", "dependencies": { + "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", - "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", @@ -2771,9 +2932,9 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3 || ^6.0.0" + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" } }, "node_modules/@sveltejs/svelte-virtual-list": { @@ -3464,6 +3625,21 @@ "@tiptap/extension-list": "^3.0.7" } }, + "node_modules/@tiptap/extension-mention": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.0.9.tgz", + "integrity": "sha512-DTQNAQkHZ+7Enlt3KvjqN6eECINlqPpET4Drzwj8Mmz9kMILc87cz3G2cwEKRrS9A1Xn3H3VpWvElWE2Wq9JHw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.0.9", + "@tiptap/pm": "^3.0.9", + "@tiptap/suggestion": "^3.0.9" + } + }, "node_modules/@tiptap/extension-node-range": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.0.7.tgz", @@ -3678,6 +3854,21 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/suggestion": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.0.9.tgz", + "integrity": "sha512-irthqfUybezo3IwR6AXvyyTOtkzwfvvst58VXZtTnR1nN6NEcrs3TQoY3bGKGbN83bdiquKh6aU2nLnZfAhoXg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.0.9", + "@tiptap/pm": "^3.0.9" + } + }, "node_modules/@tiptap/y-tiptap": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.0.tgz", @@ -4440,6 +4631,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -4805,9 +5008,10 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -5003,6 +5207,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5680,6 +5898,31 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cypress": { "version": "13.15.0", "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.0.tgz", @@ -6305,6 +6548,22 @@ "node": ">=0.10" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -6327,6 +6586,15 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -6522,6 +6790,21 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -6636,13 +6919,11 @@ "dev": true }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -6656,6 +6937,35 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es6-promise": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", @@ -6884,10 +7194,11 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7272,13 +7583,16 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -7422,16 +7736,22 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -7440,6 +7760,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -7564,12 +7898,13 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3" + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7628,11 +7963,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -7640,11 +7976,15 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { "node": ">= 0.4" }, @@ -7718,6 +8058,21 @@ "node": ">=12.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-entities": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.3.tgz", @@ -7786,6 +8141,22 @@ "entities": "^4.5.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-signature": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", @@ -7800,6 +8171,22 @@ "node": ">=0.10" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", @@ -7953,6 +8340,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", + "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -8147,6 +8535,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -8250,6 +8647,73 @@ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "dev": true }, + "node_modules/jsdom": { + "version": "24.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.1.tgz", + "integrity": "sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -8744,9 +9208,9 @@ } }, "node_modules/linkifyjs": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.1.tgz", - "integrity": "sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "license": "MIT" }, "node_modules/listr2": { @@ -9001,6 +9465,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -9050,10 +9520,11 @@ } }, "node_modules/matcher-collection/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -9071,6 +9542,16 @@ "node": "*" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -9440,6 +9921,15 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.21", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", + "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9745,14 +10235,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "engines": { - "node": "14 || >=16.14" - } - }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -10508,10 +10990,11 @@ } }, "node_modules/quick-temp/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -10736,10 +11219,11 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -10834,6 +11318,15 @@ "points-on-path": "^0.2.1" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -10915,10 +11408,11 @@ } }, "node_modules/sander/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -11349,6 +11843,21 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -12019,6 +12528,15 @@ "node": ">=12.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/symlink-or-copy": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz", @@ -12260,6 +12778,21 @@ "node": ">= 4.0.0" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -12397,9 +12930,9 @@ } }, "node_modules/undici": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.3.0.tgz", - "integrity": "sha512-Qy96NND4Dou5jKoSJ2gm8ax8AJM/Ey9o9mz7KN1bb9GP+G0l20Zw8afxTnY2f4b7hmhn/z8aC2kfArVQlAhFBw==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.11.0.tgz", + "integrity": "sha512-heTSIac3iLhsmZhUCjyS3JQEkZELateufzZuBaVM5RHXdSBMb1LPMQf5x+FH7qjsZYDP0ttAc3nnVpUB+wYbOg==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -12581,9 +13114,9 @@ } }, "node_modules/vite": { - "version": "5.4.15", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz", - "integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==", + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -13317,6 +13850,21 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walk-sync": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz", @@ -13333,10 +13881,11 @@ } }, "node_modules/walk-sync/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -13354,6 +13903,18 @@ "node": "*" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -13377,6 +13938,22 @@ "node": ">=18" } }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wheel": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz", @@ -13524,6 +14101,27 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/xmlhttprequest-ssl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", diff --git a/package.json b/package.json index 0575cd6ca5..bdbcec5fbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.6.18", + "version": "0.6.19", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -59,6 +59,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@floating-ui/dom": "^1.7.2", "@huggingface/transformers": "^3.0.0", + "@joplin/turndown-plugin-gfm": "^1.0.62", "@mediapipe/tasks-vision": "^0.10.17", "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^2.0.0", @@ -73,6 +74,7 @@ "@tiptap/extension-image": "^3.0.7", "@tiptap/extension-link": "^3.0.7", "@tiptap/extension-list": "^3.0.7", + "@tiptap/extension-mention": "^3.0.9", "@tiptap/extension-table": "^3.0.7", "@tiptap/extension-typography": "^3.0.7", "@tiptap/extension-youtube": "^3.0.7", diff --git a/pyproject.toml b/pyproject.toml index 5812a6b29a..faf0ec64e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "cryptography", "requests==2.32.4", - "aiohttp==3.11.11", + "aiohttp==3.12.15", "async-timeout", "aiocache", "aiofiles", @@ -35,7 +35,7 @@ dependencies = [ "pymongo", "redis", - "boto3==1.35.53", + "boto3==1.40.5", "argon2-cffi==23.1.0", "APScheduler==3.10.4", @@ -50,14 +50,14 @@ dependencies = [ "openai", "anthropic", - "google-genai==1.15.0", + "google-genai==1.28.0", "google-generativeai==0.8.5", "tiktoken", "langchain==0.3.26", "langchain-community==0.3.26", - "fake-useragent==2.1.0", + "fake-useragent==2.2.0", "chromadb==0.6.3", "pymilvus==2.5.0", "qdrant-client==1.14.3", @@ -65,11 +65,13 @@ dependencies = [ "playwright==1.49.1", "elasticsearch==9.0.1", "pinecone==6.0.2", + "oracledb==3.2.0", "transformers", "sentence-transformers==4.1.0", "accelerate", "colbert-ai==0.2.21", + "pyarrow==20.0.0", "einops==0.8.1", "ftfy==6.2.3", @@ -80,7 +82,7 @@ dependencies = [ "python-pptx==1.0.2", "unstructured==0.16.17", "nltk==3.9.1", - "Markdown==3.7", + "Markdown==3.8.2", "pypandoc==1.15", "pandas==2.2.3", "openpyxl==3.1.5", @@ -92,7 +94,7 @@ dependencies = [ "soundfile==0.13.1", "azure-ai-documentintelligence==1.0.2", - "pillow==11.2.1", + "pillow==11.3.0", "opencv-python-headless==4.11.0.86", "rapidocr-onnxruntime==1.4.4", "rank-bm25==0.2.2", @@ -102,7 +104,7 @@ dependencies = [ "faster-whisper==1.1.1", "PyJWT[crypto]==2.10.1", - "authlib==1.4.1", + "authlib==1.6.1", "black==25.1.0", "langfuse==2.44.0", @@ -135,7 +137,7 @@ dependencies = [ "gcp-storage-emulator>=2024.8.3", "moto[s3]>=5.0.26", - + "oracledb>=3.2.0", "posthog==5.4.0", ] diff --git a/src/app.css b/src/app.css index e1c6bb592c..7d465210ba 100644 --- a/src/app.css +++ b/src/app.css @@ -401,6 +401,17 @@ input[type='number'] { } } +.tiptap .mention { + border-radius: 0.4rem; + box-decoration-break: clone; + padding: 0.1rem 0.3rem; + @apply text-blue-900 dark:text-blue-100 bg-blue-300/20 dark:bg-blue-500/20; +} + +.tiptap .mention::after { + content: '\200B'; +} + .input-prose .tiptap ul[data-type='taskList'] { list-style: none; margin-left: 0; @@ -616,3 +627,13 @@ input[type='number'] { padding-right: 2px; white-space: nowrap; } + +body { + background: #fff; + color: #000; +} + +.dark body { + background: #171717; + color: #eee; +} diff --git a/src/app.html b/src/app.html index 1c2b7f061c..30a0ecc067 100644 --- a/src/app.html +++ b/src/app.html @@ -56,7 +56,6 @@ document.documentElement.classList.add('light'); metaThemeColorTag.setAttribute('content', '#ffffff'); } else if (localStorage.theme === 'her') { - document.documentElement.classList.add('dark'); document.documentElement.classList.add('her'); metaThemeColorTag.setAttribute('content', '#983724'); } else { diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index ca5bad0061..86f4165c30 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -465,7 +465,7 @@ export const executeToolServer = async ( ...(token && { authorization: `Bearer ${token}` }) }; - let requestOptions: RequestInit = { + const requestOptions: RequestInit = { method: httpMethod.toUpperCase(), headers }; @@ -818,7 +818,7 @@ export const generateQueries = async ( model: string, messages: object[], prompt: string, - type?: string = 'web_search' + type: string = 'web_search' ) => { let error = null; @@ -1014,7 +1014,7 @@ export const getPipelinesList = async (token: string = '') => { throw error; } - let pipelines = res?.data ?? []; + const pipelines = res?.data ?? []; return pipelines; }; @@ -1157,7 +1157,7 @@ export const getPipelines = async (token: string, urlIdx?: string) => { throw error; } - let pipelines = res?.data ?? []; + const pipelines = res?.data ?? []; return pipelines; }; diff --git a/src/lib/apis/ollama/index.ts b/src/lib/apis/ollama/index.ts index 85e08ad4e1..e1d488cc68 100644 --- a/src/lib/apis/ollama/index.ts +++ b/src/lib/apis/ollama/index.ts @@ -331,7 +331,7 @@ export const generateTextCompletion = async (token: string = '', model: string, }; export const generateChatCompletion = async (token: string = '', body: object) => { - let controller = new AbortController(); + const controller = new AbortController(); let error = null; const res = await fetch(`${OLLAMA_API_BASE_URL}/api/chat`, { diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index 68b5e58d82..282b5bdca8 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -126,7 +126,7 @@ export const getUsers = async ( let error = null; let res = null; - let searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); searchParams.set('page', `${page}`); diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index c17c57ba34..b62c6e4d16 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -35,9 +35,7 @@ let connectionType = 'external'; let azure = false; $: azure = - (url.includes('azure.com') || url.includes('cognitive.microsoft.com')) && !direct - ? true - : false; + (url.includes('azure.') || url.includes('cognitive.microsoft.com')) && !direct ? true : false; let prefixId = ''; let enable = true; diff --git a/src/lib/components/OnBoarding.svelte b/src/lib/components/OnBoarding.svelte index 55bcd85fe1..13c5bef0ce 100644 --- a/src/lib/components/OnBoarding.svelte +++ b/src/lib/components/OnBoarding.svelte @@ -62,7 +62,7 @@ - + diff --git a/src/lib/components/admin/Evaluations/FeedbackModal.svelte b/src/lib/components/admin/Evaluations/FeedbackModal.svelte index 575dc50595..d9125e68a9 100644 --- a/src/lib/components/admin/Evaluations/FeedbackModal.svelte +++ b/src/lib/components/admin/Evaluations/FeedbackModal.svelte @@ -54,6 +54,20 @@ {#if loaded} + + {$i18n.t('Chat ID')} + + + + {selectedFeedback?.meta?.chat_id ?? '-'} + + + + {#if feedbackData} {@const messageId = feedbackData?.meta?.message_id} {@const messages = feedbackData?.snapshot?.chat?.chat?.history.messages} diff --git a/src/lib/components/admin/Evaluations/Feedbacks.svelte b/src/lib/components/admin/Evaluations/Feedbacks.svelte index 61e937ee9c..5de74167df 100644 --- a/src/lib/components/admin/Evaluations/Feedbacks.svelte +++ b/src/lib/components/admin/Evaluations/Feedbacks.svelte @@ -24,6 +24,7 @@ import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import { WEBUI_BASE_URL } from '$lib/constants'; + import { config } from '$lib/stores'; export let feedbacks = []; @@ -354,17 +355,20 @@ - - - {#if feedback.data.rating.toString() === '1'} - - {:else if feedback.data.rating.toString() === '0'} - - {:else if feedback.data.rating.toString() === '-1'} - - {/if} - - + + {#if feedback?.data?.rating} + + + {#if feedback?.data?.rating.toString() === '1'} + + {:else if feedback?.data?.rating.toString() === '0'} + + {:else if feedback?.data?.rating.toString() === '-1'} + + {/if} + + + {/if} {dayjs(feedback.updated_at * 1000).fromNow()} @@ -390,7 +394,7 @@ {/if} -{#if feedbacks.length > 0} +{#if feedbacks.length > 0 && $config?.features?.enable_community_sharing} {$i18n.t('Help us create the best community leaderboard by sharing your feedback history!')} diff --git a/src/lib/components/admin/Evaluations/Leaderboard.svelte b/src/lib/components/admin/Evaluations/Leaderboard.svelte index 487945f040..f8f095ac20 100644 --- a/src/lib/components/admin/Evaluations/Leaderboard.svelte +++ b/src/lib/components/admin/Evaluations/Leaderboard.svelte @@ -151,6 +151,8 @@ } feedbacks.forEach((feedback) => { + if (!feedback?.data?.model_id || !feedback?.data?.rating) return; + const modelA = feedback.data.model_id; const statsA = getOrDefaultStats(modelA); let outcome: number; @@ -334,7 +336,9 @@ onClose={closeLeaderboardModal} /> - + {$i18n.t('Leaderboard')} diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte index 5d1e6dbb98..4b60d956c3 100644 --- a/src/lib/components/admin/Functions.svelte +++ b/src/lib/components/admin/Functions.svelte @@ -569,7 +569,7 @@ diff --git a/src/lib/components/admin/Settings/Connections.svelte b/src/lib/components/admin/Settings/Connections.svelte index c1602d07ca..11a67322b5 100644 --- a/src/lib/components/admin/Settings/Connections.svelte +++ b/src/lib/components/admin/Settings/Connections.svelte @@ -196,7 +196,6 @@ const submitHandler = async () => { updateOpenAIHandler(); updateOllamaHandler(); - updateDirectConnectionsHandler(); dispatch('save'); }; diff --git a/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte b/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte index 7a8aa49662..b82e47be76 100644 --- a/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte +++ b/src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte @@ -98,6 +98,7 @@ diff --git a/src/lib/components/admin/Settings/Database.svelte b/src/lib/components/admin/Settings/Database.svelte index de411b866e..b2ac5553de 100644 --- a/src/lib/components/admin/Settings/Database.svelte +++ b/src/lib/components/admin/Settings/Database.svelte @@ -7,6 +7,7 @@ import { config, user } from '$lib/stores'; import { toast } from 'svelte-sonner'; import { getAllUserChats } from '$lib/apis/chats'; + import { getAllUsers } from '$lib/apis/users'; import { exportConfig, importConfig } from '$lib/apis/configs'; const i18n = getContext('i18n'); @@ -20,6 +21,29 @@ saveAs(blob, `all-chats-export-${Date.now()}.json`); }; + const exportUsers = async () => { + const users = await getAllUsers(localStorage.token); + + const headers = ['id', 'name', 'email', 'role']; + + const csv = [ + headers.join(','), + ...users.users.map((user) => { + return headers + .map((header) => { + if (user[header] === null || user[header] === undefined) { + return ''; + } + return `"${String(user[header]).replace(/"/g, '""')}"`; + }) + .join(','); + }) + ].join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + saveAs(blob, 'users.csv'); + }; + onMount(async () => { // permissions = await getUserPermissions(localStorage.token); }); @@ -180,6 +204,32 @@ {$i18n.t('Export All Chats (All Users)')} + + { + exportUsers(); + }} + > + + + + + + + + {$i18n.t('Export Users')} + + {/if} diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 993cc6553f..9600f1d1a8 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -170,6 +170,19 @@ return; } + if ( + RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker' && + RAGConfig.DATALAB_MARKER_ADDITIONAL_CONFIG && + RAGConfig.DATALAB_MARKER_ADDITIONAL_CONFIG.trim() !== '' + ) { + try { + JSON.parse(RAGConfig.DATALAB_MARKER_ADDITIONAL_CONFIG); + } catch (e) { + toast.error($i18n.t('Invalid JSON format in Additional Config')); + return; + } + } + if ( RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence' && (RAGConfig.DOCUMENT_INTELLIGENCE_ENDPOINT === '' || @@ -195,10 +208,6 @@ ALLOWED_FILE_EXTENSIONS: RAGConfig.ALLOWED_FILE_EXTENSIONS.split(',') .map((ext) => ext.trim()) .filter((ext) => ext !== ''), - DATALAB_MARKER_LANGS: RAGConfig.DATALAB_MARKER_LANGS.split(',') - .map((code) => code.trim()) - .filter((code) => code !== '') - .join(', '), DOCLING_PICTURE_DESCRIPTION_LOCAL: JSON.parse( RAGConfig.DOCLING_PICTURE_DESCRIPTION_LOCAL || '{}' ), @@ -336,6 +345,21 @@ {:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker'} + + + + + - - - {$i18n.t('Languages')} + + + + {$i18n.t('Additional Config')} + + + + + + - - @@ -445,6 +478,21 @@ + + + + {$i18n.t('Format Lines')} + + + + + + - - {$i18n.t('Weight of BM25 Retrieval')} - - - - + + + + + {$i18n.t('BM25 Weight')} + + { + RAGConfig.HYBRID_BM25_WEIGHT = + (RAGConfig?.HYBRID_BM25_WEIGHT ?? null) === null ? 0.5 : null; + }} + > + {#if (RAGConfig?.HYBRID_BM25_WEIGHT ?? null) === null} + {$i18n.t('Default')} + {:else} + {$i18n.t('Custom')} + {/if} + + + + + {#if (RAGConfig?.HYBRID_BM25_WEIGHT ?? null) !== null} + + + + + + + + {$i18n.t('lexical')} + + + {$i18n.t('semantic')} + + + + + + + + + {/if} {/if} {/if} diff --git a/src/lib/components/admin/Settings/Interface.svelte b/src/lib/components/admin/Settings/Interface.svelte index 96fc7e01dc..3114bea688 100644 --- a/src/lib/components/admin/Settings/Interface.svelte +++ b/src/lib/components/admin/Settings/Interface.svelte @@ -57,14 +57,6 @@ await config.set(await getBackendConfig()); }; - onMount(async () => { - await init(); - taskConfig = await getTaskConfig(localStorage.token); - - promptSuggestions = $config?.default_prompt_suggestions ?? []; - banners = await getBanners(localStorage.token); - }); - const updateBanners = async () => { _banners.set(await setBanners(localStorage.token, banners)); }; @@ -75,6 +67,10 @@ let models = null; const init = async () => { + taskConfig = await getTaskConfig(localStorage.token); + promptSuggestions = $config?.default_prompt_suggestions ?? []; + banners = await getBanners(localStorage.token); + workspaceModels = await getBaseModels(localStorage.token); baseModels = await getModels(localStorage.token, null, false); @@ -99,6 +95,10 @@ console.debug('models', models); }; + + onMount(async () => { + await init(); + }); {#if models !== null && taskConfig} @@ -460,25 +460,27 @@ {#each promptSuggestions as prompt, promptIdx} - + + + - { - promptSuggestions.splice(promptIdx, 1); - promptSuggestions = promptSuggestions; - }} - > - + { + promptSuggestions.splice(promptIdx, 1); + promptSuggestions = promptSuggestions; + }} > - - - + + + + + {/each} diff --git a/src/lib/components/admin/Users/Groups.svelte b/src/lib/components/admin/Users/Groups.svelte index da026613f3..8b405c0b7a 100644 --- a/src/lib/components/admin/Users/Groups.svelte +++ b/src/lib/components/admin/Users/Groups.svelte @@ -66,7 +66,9 @@ }, chat: { controls: true, + valves: true, system_prompt: true, + params: true, file_upload: true, delete: true, edit: true, diff --git a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte index eaf4e45b5c..1bec9b76b6 100644 --- a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte +++ b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte @@ -48,10 +48,20 @@ }, chat: { controls: true, + valves: true, + system_prompt: true, + params: true, file_upload: true, delete: true, edit: true, - temporary: true + share: true, + export: true, + stt: true, + tts: true, + call: true, + multiple_models: true, + temporary: true, + temporary_enforced: false }, features: { direct_tool_servers: false, diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index 04e81a8076..fafff46c17 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -21,6 +21,9 @@ }, chat: { controls: true, + valves: true, + system_prompt: true, + params: true, file_upload: true, delete: true, edit: true, @@ -263,13 +266,31 @@ - - - {$i18n.t('Allow Chat System Prompt')} + {#if permissions.chat.controls} + + + {$i18n.t('Allow Chat Valves')} + + + - - + + + {$i18n.t('Allow Chat System Prompt')} + + + + + + + + {$i18n.t('Allow Chat Params')} + + + + + {/if} diff --git a/src/lib/components/admin/Users/UserList.svelte b/src/lib/components/admin/Users/UserList.svelte index 0827d17ada..4b273b0de3 100644 --- a/src/lib/components/admin/Users/UserList.svelte +++ b/src/lib/components/admin/Users/UserList.svelte @@ -142,8 +142,7 @@ type: 'error', title: 'License Error', content: - 'Exceeded the number of seats in your license. Please contact support to increase the number of seats.', - dismissable: true + 'Exceeded the number of seats in your license. Please contact support to increase the number of seats.' }} /> @@ -154,7 +153,9 @@ {:else} - + {$i18n.t('Users')} @@ -494,7 +495,9 @@ ⓘ {$i18n.t("Click on the user role button to change a user's role.")} - + {#if total > 30} + + {/if} {/if} {#if !$config?.license_metadata} diff --git a/src/lib/components/admin/Users/UserList/AddUserModal.svelte b/src/lib/components/admin/Users/UserList/AddUserModal.svelte index 7083621755..ec84fe91be 100644 --- a/src/lib/components/admin/Users/UserList/AddUserModal.svelte +++ b/src/lib/components/admin/Users/UserList/AddUserModal.svelte @@ -10,6 +10,7 @@ import Modal from '$lib/components/common/Modal.svelte'; import { generateInitialsImage } from '$lib/utils'; import XMark from '$lib/components/icons/XMark.svelte'; + import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; const i18n = getContext('i18n'); const dispatch = createEventDispatcher(); @@ -224,12 +225,13 @@ {$i18n.t('Password')} - diff --git a/src/lib/components/admin/Users/UserList/EditUserModal.svelte b/src/lib/components/admin/Users/UserList/EditUserModal.svelte index 1e69ab9912..0a5301d4c9 100644 --- a/src/lib/components/admin/Users/UserList/EditUserModal.svelte +++ b/src/lib/components/admin/Users/UserList/EditUserModal.svelte @@ -9,6 +9,7 @@ import Modal from '$lib/components/common/Modal.svelte'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import XMark from '$lib/components/icons/XMark.svelte'; + import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; const i18n = getContext('i18n'); const dispatch = createEventDispatcher(); @@ -139,12 +140,13 @@ {$i18n.t('New Password')} - diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index e22bbfbb13..f0b9ba0514 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -33,7 +33,6 @@ import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte'; export let placeholder = $i18n.t('Send a Message'); - export let transparentBackground = false; export let id = null; @@ -60,7 +59,7 @@ export let scrollToBottom: Function = () => {}; export let acceptFiles = true; - export let showFormattingButtons = true; + export let showFormattingToolbar = true; let showInputVariablesModal = false; let inputVariables: Record = {}; @@ -327,7 +326,9 @@ let imageUrl = event.target.result; // Compress the image if settings or config require it - imageUrl = await compressImageHandler(imageUrl, $settings, $config); + if ($settings?.imageCompression && $settings?.imageCompressionInChannels) { + imageUrl = await compressImageHandler(imageUrl, $settings, $config); + } files = [ ...files, @@ -700,7 +701,7 @@ bind:this={chatInputElement} json={true} messageInput={true} - {showFormattingButtons} + {showFormattingToolbar} shiftEnter={!($settings?.ctrlEnterToSend ?? false) && (!$mobile || !( diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index e7481da850..075530d608 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -28,7 +28,7 @@ import Image from '$lib/components/common/Image.svelte'; import FileItem from '$lib/components/common/FileItem.svelte'; import ProfilePreview from './Message/ProfilePreview.svelte'; - import ChatBubbleOvalEllipsis from '$lib/components/icons/ChatBubbleOvalEllipsis.svelte'; + import ChatBubbleOvalEllipsis from '$lib/components/icons/ChatBubble.svelte'; import FaceSmile from '$lib/components/icons/FaceSmile.svelte'; import ReactionPicker from './Message/ReactionPicker.svelte'; import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; diff --git a/src/lib/components/channel/Navbar.svelte b/src/lib/components/channel/Navbar.svelte index 31f94fb48a..36f0b955a3 100644 --- a/src/lib/components/channel/Navbar.svelte +++ b/src/lib/components/channel/Navbar.svelte @@ -2,14 +2,15 @@ import { getContext } from 'svelte'; import { toast } from 'svelte-sonner'; - import { showArchivedChats, showSidebar, user } from '$lib/stores'; + import { mobile, showArchivedChats, showSidebar, user } from '$lib/stores'; import { slide } from 'svelte/transition'; import { page } from '$app/stores'; import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte'; - import MenuLines from '../icons/MenuLines.svelte'; import PencilSquare from '../icons/PencilSquare.svelte'; + import Tooltip from '../common/Tooltip.svelte'; + import Sidebar from '../icons/Sidebar.svelte'; const i18n = getContext('i18n'); @@ -23,24 +24,30 @@ - - { - showSidebar.set(!$showSidebar); - }} - aria-label="Toggle Sidebar" + {#if $mobile} + - - - - - + + { + showSidebar.set(!$showSidebar); + }} + > + + + + + + + {/if} { - if (!model) { - toast.error('Model not selected'); - return; - } - const quotedText = selectedText + let selectedContent = selectedText .split('\n') .map((line) => `> ${line}`) .join('\n'); - prompt = `${quotedText}\n\nExplain`; + let selectedAction = actions.find((action) => action.id === actionId); + if (!selectedAction) { + toast.error('Action not found'); + return; + } + + let prompt = selectedAction?.prompt ?? ''; + let toolIds = []; + + // Handle: {{variableId|tool:id="toolId"}} pattern + // This regex captures variableId and toolId from {{variableId|tool:id="toolId"}} + const varToolPattern = /\{\{(.*?)\|tool:id="([^"]+)"\}\}/g; + prompt = prompt.replace(varToolPattern, (match, variableId, toolId) => { + toolIds.push(toolId); + return variableId; // Replace with just variableId + }); + + // legacy {{TOOL:toolId}} pattern (for backward compatibility) + let toolIdPattern = /\{\{TOOL:([^\}]+)\}\}/g; + let match; + while ((match = toolIdPattern.exec(prompt)) !== null) { + toolIds.push(match[1]); + } + + // Remove all TOOL placeholders from the prompt + prompt = prompt.replace(toolIdPattern, ''); + + if (prompt.includes('{{INPUT_CONTENT}}') && !floatingInput) { + prompt = prompt.replace('{{INPUT_CONTENT}}', floatingInputValue); + floatingInputValue = ''; + } + + prompt = prompt.replace('{{CONTENT}}', selectedText); + prompt = prompt.replace('{{SELECTED_CONTENT}}', selectedContent); + + content = prompt; responseContent = ''; - const [res, controller] = await chatCompletion(localStorage.token, { + + let res; + [res, controller] = await chatCompletion(localStorage.token, { model: model, messages: [ ...messages, { role: 'user', - content: prompt + content: content } ].map((message) => ({ role: message.role, content: message.content })), + ...(toolIds.length > 0 + ? { + tool_ids: toolIds + // params: { + // function_calling: 'native' + // } + } + : {}), + stream: true // Enable streaming }); @@ -196,7 +185,13 @@ }; // Process the stream in the background - await processStream(); + try { + await processStream(); + } catch (e) { + if (e.name !== 'AbortError') { + console.error(e); + } + } } else { toast.error('An error occurred while fetching the explanation'); } @@ -206,7 +201,7 @@ const messages = [ { role: 'user', - content: prompt + content: content }, { role: 'assistant', @@ -222,11 +217,23 @@ }; export const closeHandler = () => { + if (controller) { + controller.abort(); + } + + selectedAction = null; + selectedText = ''; responseContent = null; responseDone = false; floatingInput = false; floatingInputValue = ''; }; + + onDestroy(() => { + if (controller) { + controller.abort(); + } + }); - { - selectedText = window.getSelection().toString(); - floatingInput = true; + {#each actions as action} + { + selectedText = window.getSelection().toString(); + selectedAction = action; - await tick(); - setTimeout(() => { - const input = document.getElementById('floating-message-input'); - if (input) { - input.focus(); + if (action.prompt.includes('{{INPUT_CONTENT}}')) { + floatingInput = true; + floatingInputValue = ''; + + await tick(); + setTimeout(() => { + const input = document.getElementById('floating-message-input'); + if (input) { + input.focus(); + } + }, 0); + } else { + actionHandler(action.id); } - }, 0); - }} - > - - - {$i18n.t('Ask')} - - { - selectedText = window.getSelection().toString(); - explainHandler(); - }} - > - - - {$i18n.t('Explain')} - + }} + > + {#if action.icon} + + {/if} + {action.label} + + {/each} {:else} { if (e.key === 'Enter') { - askHandler(); + actionHandler(selectedAction?.id); } }} /> @@ -293,7 +299,7 @@ ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 ' : 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 m-0.5 self-center" on:click={() => { - askHandler(); + actionHandler(selectedAction?.id); }} > - + @@ -326,7 +332,7 @@ class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-xl px-3.5 py-3 w-full" > - {#if responseContent.trim() === ''} + {#if !responseContent || responseContent?.trim() === ''} {:else} diff --git a/src/lib/components/chat/Controls/Controls.svelte b/src/lib/components/chat/Controls/Controls.svelte index 1954493d1d..6a3c055fb6 100644 --- a/src/lib/components/chat/Controls/Controls.svelte +++ b/src/lib/components/chat/Controls/Controls.svelte @@ -30,70 +30,74 @@ - - {#if chatFiles.length > 0} - - - {#each chatFiles as file, fileIdx} - { - // Remove the file from the chatFiles array + {#if $user?.role === 'admin' || ($user?.permissions.chat?.controls ?? true)} + + {#if chatFiles.length > 0} + + + {#each chatFiles as file, fileIdx} + { + // Remove the file from the chatFiles array - chatFiles.splice(fileIdx, 1); - chatFiles = chatFiles; - }} - on:click={() => { - console.log(file); - }} - /> - {/each} - - - - - {/if} - - - - - - - - {#if $user?.role === 'admin' || ($user?.permissions.chat?.system_prompt ?? true)} - - - - - - - - {/if} - - {#if $user?.role === 'admin' || ($user?.permissions.chat?.controls ?? true)} - - - - - - + chatFiles.splice(fileIdx, 1); + chatFiles = chatFiles; + }} + on:click={() => { + console.log(file); + }} + /> + {/each} - - - {/if} - + + + + {/if} + + {#if $user?.role === 'admin' || ($user?.permissions.chat?.valves ?? true)} + + + + + + + + {/if} + + {#if $user?.role === 'admin' || ($user?.permissions.chat?.system_prompt ?? true)} + + + + + + + + {/if} + + {#if $user?.role === 'admin' || ($user?.permissions.chat?.params ?? true)} + + + + + + + + {/if} + + {/if} diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 147f84220a..5413ff5a6d 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -72,15 +72,15 @@ import { KokoroWorker } from '$lib/workers/KokoroWorker'; import InputVariablesModal from './MessageInput/InputVariablesModal.svelte'; + import Voice from '../icons/Voice.svelte'; const i18n = getContext('i18n'); - export let transparentBackground = false; - export let onChange: Function = () => {}; export let createMessagePair: Function; export let stopResponse: Function; export let autoScroll = false; + export let generating = false; export let atSelectedModel: Model | undefined = undefined; export let selectedModels: ['']; @@ -927,7 +927,7 @@ - + - { - prompt = e.md; - command = getCommand(); - }} - json={true} - messageInput={true} - showFormattingButtons={false} - insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false} - shiftEnter={!($settings?.ctrlEnterToSend ?? false) && - (!$mobile || - !( - 'ontouchstart' in window || - navigator.maxTouchPoints > 0 || - navigator.msMaxTouchPoints > 0 - ))} - placeholder={placeholder ? placeholder : $i18n.t('Send a Message')} - largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey} - autocomplete={$config?.features?.enable_autocomplete_generation && - ($settings?.promptAutocomplete ?? false)} - generateAutoCompletion={async (text) => { - if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) { - toast.error($i18n.t('Please select a model first.')); - } - - const res = await generateAutoCompletion( - localStorage.token, - selectedModelIds.at(0), - text, - history?.currentId - ? createMessagesList(history, history.currentId) - : null - ).catch((error) => { - console.log(error); - - return null; - }); - - console.log(res); - return res; - }} - oncompositionstart={() => (isComposing = true)} - oncompositionend={() => (isComposing = false)} - on:keydown={async (e) => { - e = e.detail.event; - - const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac - const commandsContainerElement = - document.getElementById('commands-container'); - - if (e.key === 'Escape') { - stopResponse(); - } - - // Command/Ctrl + Shift + Enter to submit a message pair - if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) { - e.preventDefault(); - createMessagePair(prompt); - } - - // Check if Ctrl + R is pressed - if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') { - e.preventDefault(); - console.log('regenerate'); - - const regenerateButton = [ - ...document.getElementsByClassName('regenerate-response-button') - ]?.at(-1); - - regenerateButton?.click(); - } - - if (prompt === '' && e.key == 'ArrowUp') { - e.preventDefault(); - - const userMessageElement = [ - ...document.getElementsByClassName('user-message') - ]?.at(-1); - - if (userMessageElement) { - userMessageElement.scrollIntoView({ block: 'center' }); - const editButton = [ - ...document.getElementsByClassName('edit-user-message-button') - ]?.at(-1); - - editButton?.click(); - } - } - - if (commandsContainerElement) { - if (commandsContainerElement && e.key === 'ArrowUp') { - e.preventDefault(); - commandsElement.selectUp(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - commandOptionButton.scrollIntoView({ block: 'center' }); - } - - if (commandsContainerElement && e.key === 'ArrowDown') { - e.preventDefault(); - commandsElement.selectDown(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - commandOptionButton.scrollIntoView({ block: 'center' }); - } - - if (commandsContainerElement && e.key === 'Tab') { - e.preventDefault(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - - commandOptionButton?.click(); - } - - if (commandsContainerElement && e.key === 'Enter') { - e.preventDefault(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - - if (commandOptionButton) { - commandOptionButton?.click(); - } else { - document.getElementById('send-message-button')?.click(); - } - } - } else { - if ( - !$mobile || + {#key $settings?.showFormattingToolbar ?? false} + { + prompt = e.md; + command = getCommand(); + }} + json={true} + messageInput={true} + showFormattingToolbar={$settings?.showFormattingToolbar ?? false} + floatingMenuPlacement={'top-start'} + insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false} + shiftEnter={!($settings?.ctrlEnterToSend ?? false) && + (!$mobile || !( 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0 - ) - ) { - if (isComposing) { - return; - } + ))} + placeholder={placeholder ? placeholder : $i18n.t('Send a Message')} + largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey} + autocomplete={$config?.features?.enable_autocomplete_generation && + ($settings?.promptAutocomplete ?? false)} + generateAutoCompletion={async (text) => { + if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) { + toast.error($i18n.t('Please select a model first.')); + } - // Uses keyCode '13' for Enter key for chinese/japanese keyboards. - // - // Depending on the user's settings, it will send the message - // either when Enter is pressed or when Ctrl+Enter is pressed. - const enterPressed = - ($settings?.ctrlEnterToSend ?? false) - ? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed - : (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey; + const res = await generateAutoCompletion( + localStorage.token, + selectedModelIds.at(0), + text, + history?.currentId + ? createMessagesList(history, history.currentId) + : null + ).catch((error) => { + console.log(error); - if (enterPressed) { - e.preventDefault(); - if (prompt !== '' || files.length > 0) { - dispatch('submit', prompt); - } + return null; + }); + + console.log(res); + return res; + }} + oncompositionstart={() => (isComposing = true)} + oncompositionend={() => (isComposing = false)} + on:keydown={async (e) => { + e = e.detail.event; + + const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac + const commandsContainerElement = + document.getElementById('commands-container'); + + if (e.key === 'Escape') { + stopResponse(); + } + + // Command/Ctrl + Shift + Enter to submit a message pair + if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) { + e.preventDefault(); + createMessagePair(prompt); + } + + // Check if Ctrl + R is pressed + if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') { + e.preventDefault(); + console.log('regenerate'); + + const regenerateButton = [ + ...document.getElementsByClassName('regenerate-response-button') + ]?.at(-1); + + regenerateButton?.click(); + } + + if (prompt === '' && e.key == 'ArrowUp') { + e.preventDefault(); + + const userMessageElement = [ + ...document.getElementsByClassName('user-message') + ]?.at(-1); + + if (userMessageElement) { + userMessageElement.scrollIntoView({ block: 'center' }); + const editButton = [ + ...document.getElementsByClassName('edit-user-message-button') + ]?.at(-1); + + editButton?.click(); } } - } - if (e.key === 'Escape') { - console.log('Escape'); - atSelectedModel = undefined; - selectedToolIds = []; - selectedFilterIds = []; + if (commandsContainerElement) { + if (commandsContainerElement && e.key === 'ArrowUp') { + e.preventDefault(); + commandsElement.selectUp(); - webSearchEnabled = false; - imageGenerationEnabled = false; - codeInterpreterEnabled = false; - } - }} - on:paste={async (e) => { - e = e.detail.event; - console.log(e); + const commandOptionButton = [ + ...document.getElementsByClassName( + 'selected-command-option-button' + ) + ]?.at(-1); + commandOptionButton.scrollIntoView({ block: 'center' }); + } - const clipboardData = e.clipboardData || window.clipboardData; + if (commandsContainerElement && e.key === 'ArrowDown') { + e.preventDefault(); + commandsElement.selectDown(); - if (clipboardData && clipboardData.items) { - for (const item of clipboardData.items) { - if (item.type.indexOf('image') !== -1) { - const blob = item.getAsFile(); - const reader = new FileReader(); + const commandOptionButton = [ + ...document.getElementsByClassName( + 'selected-command-option-button' + ) + ]?.at(-1); + commandOptionButton.scrollIntoView({ block: 'center' }); + } - reader.onload = function (e) { - files = [ - ...files, - { - type: 'image', - url: `${e.target.result}` - } - ]; - }; + if (commandsContainerElement && e.key === 'Tab') { + e.preventDefault(); - reader.readAsDataURL(blob); - } else if (item?.kind === 'file') { - const file = item.getAsFile(); - if (file) { - const _files = [file]; - await inputFilesHandler(_files); - e.preventDefault(); + const commandOptionButton = [ + ...document.getElementsByClassName( + 'selected-command-option-button' + ) + ]?.at(-1); + + commandOptionButton?.click(); + } + + if (commandsContainerElement && e.key === 'Enter') { + e.preventDefault(); + + const commandOptionButton = [ + ...document.getElementsByClassName( + 'selected-command-option-button' + ) + ]?.at(-1); + + if (commandOptionButton) { + commandOptionButton?.click(); + } else { + document.getElementById('send-message-button')?.click(); + } + } + } else { + if ( + !$mobile || + !( + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + navigator.msMaxTouchPoints > 0 + ) + ) { + if (isComposing) { + return; } - } else if (item.type === 'text/plain') { - if (($settings?.largeTextAsFile ?? false) && !shiftKey) { - const text = clipboardData.getData('text/plain'); - if (text.length > PASTED_TEXT_CHARACTER_LIMIT) { - e.preventDefault(); - const blob = new Blob([text], { type: 'text/plain' }); - const file = new File([blob], `Pasted_Text_${Date.now()}.txt`, { - type: 'text/plain' - }); + // Uses keyCode '13' for Enter key for chinese/japanese keyboards. + // + // Depending on the user's settings, it will send the message + // either when Enter is pressed or when Ctrl+Enter is pressed. + const enterPressed = + ($settings?.ctrlEnterToSend ?? false) + ? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed + : (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey; - await uploadFileHandler(file, true); + if (enterPressed) { + e.preventDefault(); + if (prompt !== '' || files.length > 0) { + dispatch('submit', prompt); } } } } - } - }} - /> + + if (e.key === 'Escape') { + console.log('Escape'); + atSelectedModel = undefined; + selectedToolIds = []; + selectedFilterIds = []; + + webSearchEnabled = false; + imageGenerationEnabled = false; + codeInterpreterEnabled = false; + } + }} + on:paste={async (e) => { + e = e.detail.event; + console.log(e); + + const clipboardData = e.clipboardData || window.clipboardData; + + if (clipboardData && clipboardData.items) { + for (const item of clipboardData.items) { + if (item.type.indexOf('image') !== -1) { + const blob = item.getAsFile(); + const reader = new FileReader(); + + reader.onload = function (e) { + files = [ + ...files, + { + type: 'image', + url: `${e.target.result}` + } + ]; + }; + + reader.readAsDataURL(blob); + } else if (item?.kind === 'file') { + const file = item.getAsFile(); + if (file) { + const _files = [file]; + await inputFilesHandler(_files); + e.preventDefault(); + } + } else if (item.type === 'text/plain') { + if (($settings?.largeTextAsFile ?? false) && !shiftKey) { + const text = clipboardData.getData('text/plain'); + + if (text.length > PASTED_TEXT_CHARACTER_LIMIT) { + e.preventDefault(); + const blob = new Blob([text], { type: 'text/plain' }); + const file = new File( + [blob], + `Pasted_Text_${Date.now()}.txt`, + { + type: 'text/plain' + } + ); + + await uploadFileHandler(file, true); + } + } + } + } + } + }} + /> + {/key} {:else} { @@ -1819,7 +1834,7 @@ {/if} - {#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true)} + {#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating} - + diff --git a/src/lib/components/chat/MessageInput/InputMenu.svelte b/src/lib/components/chat/MessageInput/InputMenu.svelte index 5013f18f01..5452689509 100644 --- a/src/lib/components/chat/MessageInput/InputMenu.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu.svelte @@ -100,7 +100,7 @@ {}; - export let sendPrompt: Function; + export let sendMessage: Function; export let continueResponse: Function; export let regenerateResponse: Function; export let mergeResponses: Function; @@ -50,6 +50,7 @@ export let readOnly = false; + export let topPadding = false; export let bottomPadding = false; export let autoScroll; @@ -294,7 +295,7 @@ history.currentId = userMessageId; await tick(); - await sendPrompt(history, userPrompt, userMessageId); + await sendMessage(history, userMessageId); } else { // Edit user message history.messages[messageId].content = content; @@ -445,6 +446,7 @@ {addMessages} {triggerScroll} {readOnly} + {topPadding} /> {/each} diff --git a/src/lib/components/chat/Messages/CitationsModal.svelte b/src/lib/components/chat/Messages/CitationsModal.svelte index e0c512565c..566f0c6e06 100644 --- a/src/lib/components/chat/Messages/CitationsModal.svelte +++ b/src/lib/components/chat/Messages/CitationsModal.svelte @@ -5,6 +5,7 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; import XMark from '$lib/components/icons/XMark.svelte'; + import Textarea from '$lib/components/common/Textarea.svelte'; const i18n = getContext('i18n'); @@ -111,15 +112,12 @@ {#if document.metadata?.parameters} - + {$i18n.t('Parameters')} - {JSON.stringify( - document.metadata.parameters, - null, - 2 - )} + + {/if} {#if showRelevance} diff --git a/src/lib/components/chat/Messages/CodeBlock.svelte b/src/lib/components/chat/Messages/CodeBlock.svelte index 0e494c1fe3..ad73408ec4 100644 --- a/src/lib/components/chat/Messages/CodeBlock.svelte +++ b/src/lib/components/chat/Messages/CodeBlock.svelte @@ -39,7 +39,7 @@ export let className = 'my-2'; export let editorClassName = ''; - export let stickyButtonsClassName = 'top-8'; + export let stickyButtonsClassName = 'top-0'; let pyodideWorker = null; diff --git a/src/lib/components/chat/Messages/CodeExecutionModal.svelte b/src/lib/components/chat/Messages/CodeExecutionModal.svelte index 802acf8a5b..141f990fd6 100644 --- a/src/lib/components/chat/Messages/CodeExecutionModal.svelte +++ b/src/lib/components/chat/Messages/CodeExecutionModal.svelte @@ -68,7 +68,6 @@ (codeExecution?.result?.error || codeExecution?.result?.output) ? 'rounded-b-none' : ''} - stickyButtonsClassName="top-0" run={false} /> diff --git a/src/lib/components/chat/Messages/ContentRenderer.svelte b/src/lib/components/chat/Messages/ContentRenderer.svelte index 54cd2a5aab..f2f5f2259f 100644 --- a/src/lib/components/chat/Messages/ContentRenderer.svelte +++ b/src/lib/components/chat/Messages/ContentRenderer.svelte @@ -27,6 +27,7 @@ export let save = false; export let preview = false; export let floatingButtons = true; + export let topPadding = false; export let onSave = (e) => {}; export let onSourceClick = (e) => {}; @@ -34,7 +35,6 @@ export let onAddMessages = (e) => {}; let contentContainerElement; - let floatingButtonsElement; const updateButtonPosition = (event) => { @@ -135,6 +135,7 @@ {save} {preview} {done} + {topPadding} sourceIds={(sources ?? []).reduce((acc, s) => { let ids = []; s.document.forEach((document, index) => { @@ -195,6 +196,7 @@ 0 diff --git a/src/lib/components/chat/Messages/Markdown.svelte b/src/lib/components/chat/Messages/Markdown.svelte index 96ec6e06ba..a2ac73dc45 100644 --- a/src/lib/components/chat/Messages/Markdown.svelte +++ b/src/lib/components/chat/Messages/Markdown.svelte @@ -14,6 +14,7 @@ export let model = null; export let save = false; export let preview = false; + export let topPadding = false; export let sourceIds = []; @@ -51,6 +52,7 @@ {done} {save} {preview} + {topPadding} {onTaskClick} {onSourceClick} {onSave} diff --git a/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte b/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte index d246bb36d9..13bee6e111 100644 --- a/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte @@ -122,6 +122,11 @@ {:else if token.text.includes(` {:else} - {token.text} + {@const br = token.text.match(//)} + {#if br} + + {:else} + {token.text} + {/if} {/if} {/if} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte index 70626a44d4..3955010630 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte @@ -32,6 +32,7 @@ export let save = false; export let preview = false; + export let topPadding = false; export let onSave: Function = () => {}; export let onUpdate: Function = () => {}; @@ -105,6 +106,7 @@ {attributes} {save} {preview} + stickyButtonsClassName={topPadding ? 'top-8' : 'top-0'} onSave={(value) => { onSave({ raw: token.raw, diff --git a/src/lib/components/chat/Messages/Message.svelte b/src/lib/components/chat/Messages/Message.svelte index 7dc7125598..dd48e22506 100644 --- a/src/lib/components/chat/Messages/Message.svelte +++ b/src/lib/components/chat/Messages/Message.svelte @@ -41,6 +41,7 @@ export let addMessages; export let triggerScroll; export let readOnly = false; + export let topPadding = false; {:else if (history.messages[history.messages[messageId].parentId]?.models?.length ?? 1) === 1} {:else} {/if} {/if} diff --git a/src/lib/components/chat/Messages/MultiResponseMessages.svelte b/src/lib/components/chat/Messages/MultiResponseMessages.svelte index 3b3dd9b194..231fca66c0 100644 --- a/src/lib/components/chat/Messages/MultiResponseMessages.svelte +++ b/src/lib/components/chat/Messages/MultiResponseMessages.svelte @@ -3,7 +3,7 @@ import { onMount, tick, getContext } from 'svelte'; import { createEventDispatcher } from 'svelte'; - import { mobile, settings } from '$lib/stores'; + import { mobile, models, settings } from '$lib/stores'; import { generateMoACompletion } from '$lib/apis'; import { updateChatById } from '$lib/apis/chats'; @@ -17,6 +17,8 @@ import Name from './Name.svelte'; import Skeleton from './Skeleton.svelte'; import localizedFormat from 'dayjs/plugin/localizedFormat'; + import ProfileImage from './ProfileImage.svelte'; + import { WEBUI_BASE_URL } from '$lib/constants'; const i18n = getContext('i18n'); dayjs.extend(localizedFormat); @@ -46,6 +48,8 @@ export let triggerScroll: Function; + export let topPadding = false; + const dispatch = createEventDispatcher(); let currentMessageId; @@ -53,6 +57,8 @@ let groupedMessageIds = {}; let groupedMessageIdsIdx = {}; + let selectedModelIdx = null; + let message = JSON.parse(JSON.stringify(history.messages[messageId])); $: if (history.messages) { if (JSON.stringify(message) !== JSON.stringify(history.messages[messageId])) { @@ -183,11 +189,30 @@ } }, {}); + selectedModelIdx = history.messages[messageId]?.modelIdx; + console.log(groupedMessageIds, groupedMessageIdsIdx); await tick(); }; + const onGroupClick = async (_messageId, modelIdx) => { + if (messageId != _messageId) { + let currentMessageId = _messageId; + let messageChildrenIds = history.messages[currentMessageId].childrenIds; + while (messageChildrenIds.length !== 0) { + currentMessageId = messageChildrenIds.at(-1); + messageChildrenIds = history.messages[currentMessageId].childrenIds; + } + history.currentId = currentMessageId; + selectedModelIdx = modelIdx; + + // await tick(); + // await updateChat(); + // triggerScroll(); + } + }; + const mergeResponsesHandler = async () => { const responses = Object.keys(groupedMessageIds).map((modelIdx) => { const { messageIds } = groupedMessageIds[modelIdx]; @@ -217,37 +242,58 @@ class="flex snap-x snap-mandatory overflow-x-auto scrollbar-hidden" id="responses-container-{chatId}-{parentMessage.id}" > - {#each Object.keys(groupedMessageIds) as modelIdx} - {#if groupedMessageIdsIdx[modelIdx] !== undefined && groupedMessageIds[modelIdx].messageIds.length > 0} - - - {@const _messageId = - groupedMessageIds[modelIdx].messageIds[groupedMessageIdsIdx[modelIdx]]} + {#if $settings?.displayMultiModelResponsesInTabs ?? false} + + + + {#each Object.keys(groupedMessageIds) as modelIdx} + {#if groupedMessageIdsIdx[modelIdx] !== undefined && groupedMessageIds[modelIdx].messageIds.length > 0} + + - { - if (messageId != _messageId) { - let currentMessageId = _messageId; - let messageChildrenIds = history.messages[currentMessageId].childrenIds; - while (messageChildrenIds.length !== 0) { - currentMessageId = messageChildrenIds.at(-1); - messageChildrenIds = history.messages[currentMessageId].childrenIds; - } - history.currentId = currentMessageId; - // await tick(); - // await updateChat(); - // triggerScroll(); - } - }} - > + {@const _messageId = + groupedMessageIds[modelIdx].messageIds[groupedMessageIdsIdx[modelIdx]]} + + {@const model = $models.find((m) => m.id === history.messages[_messageId]?.model)} + + { + if (selectedModelIdx != modelIdx) { + selectedModelIdx = modelIdx; + } + + onGroupClick(_messageId, modelIdx); + }} + > + + + + + {model ? `${model.name}` : history.messages[_messageId]?.model} + + + + {/if} + {/each} + + + + {#if selectedModelIdx !== null} + {@const _messageId = + groupedMessageIds[selectedModelIdx].messageIds[ + groupedMessageIdsIdx[selectedModelIdx] + ]} {#key history.currentId} {#if message} gotoMessage(modelIdx, messageIdx)} - showPreviousMessage={() => showPreviousMessage(modelIdx)} - showNextMessage={() => showNextMessage(modelIdx)} + siblings={groupedMessageIds[selectedModelIdx].messageIds} + gotoMessage={(message, messageIdx) => gotoMessage(selectedModelIdx, messageIdx)} + showPreviousMessage={() => showPreviousMessage(selectedModelIdx)} + showNextMessage={() => showNextMessage(selectedModelIdx)} {setInputText} {updateChat} {editMessage} @@ -269,20 +315,78 @@ {actionMessage} {submitMessage} {continueResponse} - regenerateResponse={async (message) => { - regenerateResponse(message); + regenerateResponse={async (message, prompt = null) => { + regenerateResponse(message, prompt); await tick(); - groupedMessageIdsIdx[modelIdx] = - groupedMessageIds[modelIdx].messageIds.length - 1; + groupedMessageIdsIdx[selectedModelIdx] = + groupedMessageIds[selectedModelIdx].messageIds.length - 1; }} {addMessages} {readOnly} + {topPadding} /> {/if} {/key} - - {/if} - {/each} + {/if} + + {:else} + {#each Object.keys(groupedMessageIds) as modelIdx} + {#if groupedMessageIdsIdx[modelIdx] !== undefined && groupedMessageIds[modelIdx].messageIds.length > 0} + + + {@const _messageId = + groupedMessageIds[modelIdx].messageIds[groupedMessageIdsIdx[modelIdx]]} + + { + onGroupClick(_messageId, modelIdx); + }} + > + {#key history.currentId} + {#if message} + gotoMessage(modelIdx, messageIdx)} + showPreviousMessage={() => showPreviousMessage(modelIdx)} + showNextMessage={() => showNextMessage(modelIdx)} + {setInputText} + {updateChat} + {editMessage} + {saveMessage} + {rateMessage} + {deleteMessage} + {actionMessage} + {submitMessage} + {continueResponse} + regenerateResponse={async (message, prompt = null) => { + regenerateResponse(message, prompt); + await tick(); + groupedMessageIdsIdx[modelIdx] = + groupedMessageIds[modelIdx].messageIds.length - 1; + }} + {addMessages} + {readOnly} + {topPadding} + /> + {/if} + {/key} + + {/if} + {/each} + {/if} {#if !readOnly} @@ -296,7 +400,7 @@ {#if history.messages[messageId]?.merged?.status} {@const message = history.messages[messageId]?.merged} - + {$i18n.t('Merged Response')} @@ -328,7 +432,7 @@ id="merge-response-button" class="{true ? 'visible' - : 'invisible group-hover:visible'} p-1 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 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition" on:click={() => { mergeResponsesHandler(); }} diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 6924fc755c..62460baf2c 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -51,6 +51,7 @@ import FollowUps from './ResponseMessage/FollowUps.svelte'; import { fade } from 'svelte/transition'; import { flyAndScale } from '$lib/utils/transitions'; + import RegenerateMenu from './ResponseMessage/RegenerateMenu.svelte'; interface MessageType { id: string; @@ -137,6 +138,7 @@ export let isLastMessage = true; export let readOnly = false; + export let topPadding = false; let buttonsContainerElement: HTMLDivElement; let showDeleteConfirm = false; @@ -797,14 +799,17 @@ { continueResponse(); }} @@ -1343,47 +1348,70 @@ {/if} - - { - showRateComment = false; - regenerateResponse(message); + { + showRateComment = false; + regenerateResponse(message); - (model?.actions ?? []).forEach((action) => { - dispatch('action', { - id: action.id, - event: { - id: 'regenerate-response', - data: { - messageId: message.id - } + (model?.actions ?? []).forEach((action) => { + dispatch('action', { + id: action.id, + event: { + id: 'regenerate-response', + data: { + messageId: message.id } - }); + } }); - }} - > - + + { + showRateComment = false; + regenerateResponse(message, prompt); + + (model?.actions ?? []).forEach((action) => { + dispatch('action', { + id: action.id, + event: { + id: 'regenerate-response', + data: { + messageId: message.id + } + } + }); + }); + }} + > + + - - - - + + + + + + {#if siblings.length > 1} @@ -1393,7 +1421,7 @@ id="delete-response-button" class="{isLastMessage ? '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" on:click={() => { showDeleteConfirm = true; }} diff --git a/src/lib/components/chat/Messages/ResponseMessage/FollowUps.svelte b/src/lib/components/chat/Messages/ResponseMessage/FollowUps.svelte index 58577281c6..5465bba368 100644 --- a/src/lib/components/chat/Messages/ResponseMessage/FollowUps.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage/FollowUps.svelte @@ -1,4 +1,5 @@ + + { + if (e.detail === false) { + onClose(); + } + }} + align="end" +> + + + + + + { + if (e.key === 'Enter') { + onRegenerate(inputValue); + show = false; + } + }} + /> + + + { + onRegenerate(inputValue); + show = false; + }} + > + + + + + + + + { + onRegenerate(); + show = false; + }} + > + + + + {$i18n.t('Try Again')} + + + { + onRegenerate($i18n.t('Add Details')); + }} + > + + {$i18n.t('Add Details')} + + + { + onRegenerate($i18n.t('More Concise')); + }} + > + + {$i18n.t('More Concise')} + + + + diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index d14771c950..be6634838c 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -23,6 +23,7 @@ export let user; + export let chatId; export let history; export let messageId; @@ -37,6 +38,7 @@ export let isFirstMessage: boolean; export let readOnly: boolean; + export let topPadding = false; let showDeleteConfirm = false; @@ -317,7 +319,7 @@ : ' w-full'}" > {#if message.content} - + {/if} diff --git a/src/lib/components/chat/ModelSelector/ModelItem.svelte b/src/lib/components/chat/ModelSelector/ModelItem.svelte index 405902fc26..697df0871a 100644 --- a/src/lib/components/chat/ModelSelector/ModelItem.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItem.svelte @@ -14,6 +14,8 @@ import ModelItemMenu from './ModelItemMenu.svelte'; import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte'; import { toast } from 'svelte-sonner'; + import Tag from '$lib/components/icons/Tag.svelte'; + import Label from '$lib/components/icons/Label.svelte'; const i18n = getContext('i18n'); @@ -55,7 +57,7 @@ }} > - {#if (item?.model?.tags ?? []).length > 0} + @@ -136,6 +138,26 @@ + {#if (item?.model?.tags ?? []).length > 0} + {#key item.model.id} + + + {#each item.model?.tags.sort((a, b) => a.name.localeCompare(b.name)) as tag} + + + {tag.name} + + + {/each} + + + + + + + {/key} + {/if} + {#if item.model?.direct} @@ -233,6 +255,7 @@ }} > { e.preventDefault(); diff --git a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte index df5e0fb3ca..f74444a382 100644 --- a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte @@ -32,7 +32,12 @@ typeahead={false} > - + @@ -45,8 +50,9 @@ align="end" transition={flyAndScale} > - { e.stopPropagation(); @@ -69,9 +75,9 @@ {$i18n.t('Keep in Sidebar')} {/if} - + - { @@ -85,6 +91,6 @@ {$i18n.t('Copy Link')} - + diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index 982bb0a6c8..8b9b7b8117 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -394,14 +394,17 @@ class="w-full text-sm bg-transparent outline-hidden" placeholder={searchPlaceholder} autocomplete="off" + aria-label={$i18n.t('Search In Models')} on:keydown={(e) => { if (e.code === 'Enter' && filteredItems.length > 0) { value = filteredItems[selectedModelIdx].value; show = false; return; // dont need to scroll on selection } else if (e.code === 'ArrowDown') { + e.stopPropagation(); selectedModelIdx = Math.min(selectedModelIdx + 1, filteredItems.length - 1); } else if (e.code === 'ArrowUp') { + e.stopPropagation(); selectedModelIdx = Math.max(selectedModelIdx - 1, 0); } else { // if the user types something, reset to the top selection. @@ -436,6 +439,7 @@ selectedConnectionType === '' ? '' : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize" + aria-pressed={selectedTag === '' && selectedConnectionType === ''} on:click={() => { selectedConnectionType = ''; selectedTag = ''; @@ -450,6 +454,7 @@ class="min-w-fit outline-none p-1.5 {selectedConnectionType === 'local' ? '' : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize" + aria-pressed={selectedConnectionType === 'local'} on:click={() => { selectedTag = ''; selectedConnectionType = 'local'; @@ -464,6 +469,7 @@ class="min-w-fit outline-none p-1.5 {selectedConnectionType === 'external' ? '' : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize" + aria-pressed={selectedConnectionType === 'external'} on:click={() => { selectedTag = ''; selectedConnectionType = 'external'; @@ -478,6 +484,7 @@ 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" + aria-pressed={selectedConnectionType === 'direct'} on:click={() => { selectedTag = ''; selectedConnectionType = 'direct'; @@ -492,6 +499,7 @@ 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" + aria-pressed={selectedTag === tag} on:click={() => { selectedConnectionType = ''; selectedTag = tag; @@ -613,7 +621,7 @@ {#if showTemporaryChatControl} - { temporaryChatEnabled.set(!$temporaryChatEnabled); @@ -642,7 +650,7 @@ - + {:else} diff --git a/src/lib/components/chat/Navbar.svelte b/src/lib/components/chat/Navbar.svelte index fb21eeac42..17e16b011d 100644 --- a/src/lib/components/chat/Navbar.svelte +++ b/src/lib/components/chat/Navbar.svelte @@ -24,11 +24,11 @@ import Tooltip from '../common/Tooltip.svelte'; import Menu from '$lib/components/layout/Navbar/Menu.svelte'; import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte'; - import MenuLines from '../icons/MenuLines.svelte'; import AdjustmentsHorizontal from '../icons/AdjustmentsHorizontal.svelte'; import PencilSquare from '../icons/PencilSquare.svelte'; import Banner from '../common/Banner.svelte'; + import Sidebar from '../icons/Sidebar.svelte'; const i18n = getContext('i18n'); @@ -59,50 +59,37 @@ aria-label="New Chat" /> - + - + - - { - showSidebar.set(!$showSidebar); - }} - aria-label="Toggle Sidebar" + {#if $mobile} + - - - - - - {#if !$mobile} - + { - initNewChat(); + showSidebar.set(!$showSidebar); }} - aria-label="New Chat" > - - + + - {/if} - + + {/if} { - await showControls.set(!$showControls); - }} - aria-label="Controls" - > - - - - - + {#if $user?.role === 'admin' || ($user?.permissions.chat?.controls ?? true)} + + { + await showControls.set(!$showControls); + }} + aria-label="Controls" + > + + + + + + {/if} {#if $mobile} diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte index 5f9f1cb658..8d68cb0fae 100644 --- a/src/lib/components/chat/Placeholder.svelte +++ b/src/lib/components/chat/Placeholder.svelte @@ -29,8 +29,6 @@ const i18n = getContext('i18n'); - export let transparentBackground = false; - export let createMessagePair: Function; export let stopResponse: Function; @@ -55,6 +53,7 @@ export let webSearchEnabled = false; export let onSelect = (e) => {}; + export let onChange = (e) => {}; export let toolServers = []; @@ -78,8 +77,8 @@ className="w-full flex justify-center mb-0.5" placement="top" > - - {$i18n.t('Temporary Chat')} + + {$i18n.t('Temporary Chat')} {/if} @@ -220,19 +219,10 @@ bind:atSelectedModel bind:showCommands {toolServers} - {transparentBackground} {stopResponse} {createMessagePair} placeholder={$i18n.t('How can I help you today?')} - onChange={(input) => { - if (!$temporaryChatEnabled) { - if (input.prompt !== null) { - sessionStorage.setItem(`chat-input`, JSON.stringify(input)); - } else { - sessionStorage.removeItem(`chat-input`); - } - } - }} + {onChange} on:upload={(e) => { dispatch('upload', e.detail); }} diff --git a/src/lib/components/chat/Placeholder/ChatList.svelte b/src/lib/components/chat/Placeholder/ChatList.svelte index 1e48298ec5..62869f7fc2 100644 --- a/src/lib/components/chat/Placeholder/ChatList.svelte +++ b/src/lib/components/chat/Placeholder/ChatList.svelte @@ -5,6 +5,8 @@ import dayjs from 'dayjs'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import { getTimeRange } from '$lib/utils'; + import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; + import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; dayjs.extend(localizedFormat); @@ -20,15 +22,86 @@ ...chat, time_range: getTimeRange(chat.updated_at) })); + + chatList.sort((a, b) => { + if (direction === 'asc') { + return a[orderBy] > b[orderBy] ? 1 : -1; + } else { + return a[orderBy] < b[orderBy] ? 1 : -1; + } + }); } }; + const setSortKey = (key) => { + if (orderBy === key) { + direction = direction === 'asc' ? 'desc' : 'asc'; + } else { + orderBy = key; + direction = 'asc'; + } + + init(); + }; + + let orderBy = 'updated_at'; + let direction = 'desc'; // 'asc' or 'desc' + $: if (chats) { init(); } {#if chatList} + {#if chatList.length > 0} + + setSortKey('title')} + > + + {$i18n.t('Title')} + + {#if orderBy === 'title'} + {#if direction === 'asc'} + + {:else} + + {/if} + + {:else} + + + + {/if} + + + setSortKey('updated_at')} + > + + {$i18n.t('Updated at')} + + {#if orderBy === 'updated_at'} + {#if direction === 'asc'} + + {:else} + + {/if} + + {:else} + + + + {/if} + + + + {/if} + {#if chatList.length === 0} {$i18n.t('Current Password')} - {$i18n.t('New Password')} - {$i18n.t('Confirm Password')} - + {#if admin} + + + + + {$i18n.t('Stream Delta Chunk Size')} + + { + params.stream_delta_chunk_size = + (params?.stream_delta_chunk_size ?? null) === null ? 1 : null; + }} + > + {#if (params?.stream_delta_chunk_size ?? null) === null} + {$i18n.t('Default')} + {:else} + {$i18n.t('Custom')} + {/if} + + + + + {#if (params?.stream_delta_chunk_size ?? null) !== null} + + + + + + + + + {/if} + + {/if} + e) : undefined, @@ -117,7 +120,7 @@ }); const applyTheme = (_theme: string) => { - let themeToApply = _theme === 'oled-dark' ? 'dark' : _theme; + let themeToApply = _theme === 'oled-dark' ? 'dark' : _theme === 'her' ? 'light' : _theme; if (_theme === 'system') { themeToApply = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; @@ -233,11 +236,17 @@ - {#if $i18n.language === 'en-US'} - + {#if $i18n.language === 'en-US' && !($config?.license_metadata ?? false)} + Couldn't find your language? @@ -289,7 +298,9 @@ {$i18n.t('Advanced Parameters')} { showAdvanced = !showAdvanced; diff --git a/src/lib/components/chat/Settings/Interface.svelte b/src/lib/components/chat/Settings/Interface.svelte index 2b517471a9..6c6aab0b57 100644 --- a/src/lib/components/chat/Settings/Interface.svelte +++ b/src/lib/components/chat/Settings/Interface.svelte @@ -5,6 +5,10 @@ import Tooltip from '$lib/components/common/Tooltip.svelte'; import { updateUserInfo } from '$lib/apis/users'; import { getUserPosition } from '$lib/utils'; + import Minus from '$lib/components/icons/Minus.svelte'; + import Plus from '$lib/components/icons/Plus.svelte'; + import Switch from '$lib/components/common/Switch.svelte'; + import ManageFloatingActionButtonsModal from './Interface/ManageFloatingActionButtonsModal.svelte'; const dispatch = createEventDispatcher(); const i18n = getContext('i18n'); @@ -36,8 +40,10 @@ let highContrastMode = false; let detectArtifacts = true; + let displayMultiModelResponsesInTabs = false; let richTextInput = true; + let showFormattingToolbar = false; let insertPromptAsRichText = false; let promptAutocomplete = false; @@ -56,11 +62,15 @@ let collapseCodeBlocks = false; let expandDetails = false; + let showFloatingActionButtons = true; + let floatingActionButtons = null; + let imageCompression = false; let imageCompressionSize = { width: '', height: '' }; + let imageCompressionInChannels = true; // chat export let stylizedPdfExport = true; @@ -78,109 +88,14 @@ let iframeSandboxAllowSameOrigin = false; let iframeSandboxAllowForms = false; - const toggleExpandDetails = () => { - expandDetails = !expandDetails; - saveSettings({ expandDetails }); - }; - - const toggleCollapseCodeBlocks = () => { - collapseCodeBlocks = !collapseCodeBlocks; - saveSettings({ collapseCodeBlocks }); - }; - - const toggleSplitLargeChunks = async () => { - splitLargeChunks = !splitLargeChunks; - saveSettings({ splitLargeChunks: splitLargeChunks }); - }; - - const toggleHighContrastMode = async () => { - highContrastMode = !highContrastMode; - saveSettings({ highContrastMode: highContrastMode }); - }; - - const togglePromptAutocomplete = async () => { - promptAutocomplete = !promptAutocomplete; - saveSettings({ promptAutocomplete: promptAutocomplete }); - }; - - const togglesScrollOnBranchChange = async () => { - scrollOnBranchChange = !scrollOnBranchChange; - saveSettings({ scrollOnBranchChange: scrollOnBranchChange }); - }; - - const toggleWidescreenMode = async () => { - widescreenMode = !widescreenMode; - saveSettings({ widescreenMode: widescreenMode }); - }; - - const toggleChatBubble = async () => { - chatBubble = !chatBubble; - saveSettings({ chatBubble: chatBubble }); - }; + let showManageFloatingActionButtonsModal = false; const toggleLandingPageMode = async () => { landingPageMode = landingPageMode === '' ? 'chat' : ''; saveSettings({ landingPageMode: landingPageMode }); }; - const toggleShowUpdateToast = async () => { - showUpdateToast = !showUpdateToast; - saveSettings({ showUpdateToast: showUpdateToast }); - }; - - const toggleNotificationSound = async () => { - notificationSound = !notificationSound; - saveSettings({ notificationSound: notificationSound }); - }; - - const toggleNotificationSoundAlways = async () => { - notificationSoundAlways = !notificationSoundAlways; - saveSettings({ notificationSoundAlways: notificationSoundAlways }); - }; - - const toggleShowChangelog = async () => { - showChangelog = !showChangelog; - saveSettings({ showChangelog: showChangelog }); - }; - - const toggleShowUsername = async () => { - showUsername = !showUsername; - saveSettings({ showUsername: showUsername }); - }; - - const toggleEmojiInCall = async () => { - showEmojiInCall = !showEmojiInCall; - saveSettings({ showEmojiInCall: showEmojiInCall }); - }; - - const toggleVoiceInterruption = async () => { - voiceInterruption = !voiceInterruption; - saveSettings({ voiceInterruption: voiceInterruption }); - }; - - const toggleImageCompression = async () => { - imageCompression = !imageCompression; - saveSettings({ imageCompression }); - }; - - const toggleChatFadeStreamingText = async () => { - chatFadeStreamingText = !chatFadeStreamingText; - saveSettings({ chatFadeStreamingText: chatFadeStreamingText }); - }; - - const toggleHapticFeedback = async () => { - hapticFeedback = !hapticFeedback; - saveSettings({ hapticFeedback: hapticFeedback }); - }; - - const toggleStylizedPdfExport = async () => { - stylizedPdfExport = !stylizedPdfExport; - saveSettings({ stylizedPdfExport: stylizedPdfExport }); - }; - const toggleUserLocation = async () => { - userLocation = !userLocation; - if (userLocation) { const position = await getUserPosition().catch((error) => { toast.error(error.message); @@ -199,7 +114,6 @@ }; const toggleTitleAutoGenerate = async () => { - titleAutoGenerate = !titleAutoGenerate; saveSettings({ title: { ...$settings.title, @@ -208,46 +122,6 @@ }); }; - const toggleAutoFollowUps = async () => { - autoFollowUps = !autoFollowUps; - saveSettings({ autoFollowUps }); - }; - - const toggleAutoTags = async () => { - autoTags = !autoTags; - saveSettings({ autoTags }); - }; - - const toggleDetectArtifacts = async () => { - detectArtifacts = !detectArtifacts; - saveSettings({ detectArtifacts }); - }; - - const toggleRichTextInput = async () => { - richTextInput = !richTextInput; - saveSettings({ richTextInput }); - }; - - const toggleInsertPromptAsRichText = async () => { - insertPromptAsRichText = !insertPromptAsRichText; - saveSettings({ insertPromptAsRichText }); - }; - - const toggleKeepFollowUpPrompts = async () => { - keepFollowUpPrompts = !keepFollowUpPrompts; - saveSettings({ keepFollowUpPrompts }); - }; - - const toggleInsertFollowUpPrompt = async () => { - insertFollowUpPrompt = !insertFollowUpPrompt; - saveSettings({ insertFollowUpPrompt }); - }; - - const toggleLargeTextAsFile = async () => { - largeTextAsFile = !largeTextAsFile; - saveSettings({ largeTextAsFile }); - }; - const toggleResponseAutoCopy = async () => { const permission = await navigator.clipboard .readText() @@ -258,12 +132,10 @@ return ''; }); - console.log(permission); - if (permission === 'granted') { - responseAutoCopy = !responseAutoCopy; saveSettings({ responseAutoCopy: responseAutoCopy }); } else { + responseAutoCopy = false; toast.error( $i18n.t( 'Clipboard write permission denied. Please check your browser settings to grant the necessary access.' @@ -272,11 +144,6 @@ } }; - const toggleCopyFormatted = async () => { - copyFormatted = !copyFormatted; - saveSettings({ copyFormatted }); - }; - const toggleChangeChatDirection = async () => { if (chatDirection === 'auto') { chatDirection = 'LTR'; @@ -305,16 +172,6 @@ saveSettings({ webSearch: webSearch }); }; - const toggleIframeSandboxAllowSameOrigin = async () => { - iframeSandboxAllowSameOrigin = !iframeSandboxAllowSameOrigin; - saveSettings({ iframeSandboxAllowSameOrigin }); - }; - - const toggleIframeSandboxAllowForms = async () => { - iframeSandboxAllowForms = !iframeSandboxAllowForms; - saveSettings({ iframeSandboxAllowForms }); - }; - onMount(async () => { titleAutoGenerate = $settings?.title?.auto ?? true; autoTags = $settings?.autoTags ?? true; @@ -332,9 +189,11 @@ showEmojiInCall = $settings?.showEmojiInCall ?? false; voiceInterruption = $settings?.voiceInterruption ?? false; + displayMultiModelResponsesInTabs = $settings?.displayMultiModelResponsesInTabs ?? false; chatFadeStreamingText = $settings?.chatFadeStreamingText ?? true; richTextInput = $settings?.richTextInput ?? true; + showFormattingToolbar = $settings?.showFormattingToolbar ?? false; insertPromptAsRichText = $settings?.insertPromptAsRichText ?? false; promptAutocomplete = $settings?.promptAutocomplete ?? false; @@ -366,8 +225,12 @@ hapticFeedback = $settings?.hapticFeedback ?? false; ctrlEnterToSend = $settings?.ctrlEnterToSend ?? false; + showFloatingActionButtons = $settings?.showFloatingActionButtons ?? true; + floatingActionButtons = $settings?.floatingActionButtons ?? null; + imageCompression = $settings?.imageCompression ?? false; imageCompressionSize = $settings?.imageCompressionSize ?? { width: '', height: '' }; + imageCompressionInChannels = $settings?.imageCompressionInChannels ?? true; defaultModelId = $settings?.models?.at(0) ?? ''; if ($config?.default_models) { @@ -379,6 +242,15 @@ }); + { + floatingActionButtons = buttons; + saveSettings({ floatingActionButtons }); + }} +/> + - {$i18n.t('UI')} + {$i18n.t('UI')} @@ -425,19 +297,175 @@ {$i18n.t('High Contrast Mode')} ({$i18n.t('Beta')}) + + { + saveSettings({ highContrastMode }); + }} + /> + + + + + + + + {$i18n.t('Notification Sound')} + + + + { + saveSettings({ notificationSound }); + }} + /> + + + + + {#if notificationSound} + + + + {$i18n.t('Always Play Notification Sound')} + + + + { + saveSettings({ notificationSoundAlways }); + }} + /> + + + + {/if} + + + + {$i18n.t('Allow User Location')} + + + { + toggleUserLocation(); + }} + /> + + + + + + + + {$i18n.t('Haptic Feedback')} ({$i18n.t('Android')}) + + + + { + saveSettings({ hapticFeedback }); + }} + /> + + + + + + + + {$i18n.t('Copy Formatted Text')} + + + + { + saveSettings({ copyFormatted }); + }} + /> + + + + + {#if $user?.role === 'admin'} + + + + {$i18n.t('Toast notifications for new updates')} + + + + { + saveSettings({ showUpdateToast }); + }} + /> + + + + + + + + {$i18n.t(`Show "What's New" modal on login`)} + + + + { + saveSettings({ showChangelog }); + }} + /> + + + + {/if} + + {$i18n.t('Chat')} + + + + + {$i18n.t('Chat direction')} + + { - toggleHighContrastMode(); - }} + on:click={toggleChangeChatDirection} type="button" > - {#if highContrastMode === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} + + {chatDirection === 'LTR' + ? $i18n.t('LTR') + : chatDirection === 'RTL' + ? $i18n.t('RTL') + : $i18n.t('Auto')} + @@ -449,557 +477,16 @@ { toggleLandingPageMode(); }} type="button" > - {#if landingPageMode === ''} - {$i18n.t('Default')} - {:else} - {$i18n.t('Chat')} - {/if} - - - - - - - - {$i18n.t('Chat Bubble UI')} - - - { - toggleChatBubble(); - }} - type="button" - > - {#if chatBubble === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - {#if !$settings.chatBubble} - - - - {$i18n.t('Display the username instead of You in the Chat')} - - - { - toggleShowUsername(); - }} - type="button" + {notificationSound === true ? $i18n.t('On') : $i18n.t('Off')} - {#if showUsername === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - {/if} - - - - - {$i18n.t('Widescreen Mode')} - - - { - toggleWidescreenMode(); - }} - aria-labelledby="widescreen-mode-label" - type="button" - > - {#if widescreenMode === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t('Chat direction')} - - - - {#if chatDirection === 'LTR'} - {$i18n.t('LTR')} - {:else if chatDirection === 'RTL'} - {$i18n.t('RTL')} - {:else} - {$i18n.t('Auto')} - {/if} - - - - - - - - {$i18n.t('Notification Sound')} - - - { - toggleNotificationSound(); - }} - type="button" - > - {#if notificationSound === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - {#if notificationSound} - - - - {$i18n.t('Always Play Notification Sound')} - - - { - toggleNotificationSoundAlways(); - }} - type="button" - > - {#if notificationSoundAlways === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - {/if} - - {#if $user?.role === 'admin'} - - - - {$i18n.t('Toast notifications for new updates')} - - - { - toggleShowUpdateToast(); - }} - type="button" - > - {#if showUpdateToast === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t(`Show "What's New" modal on login`)} - - - { - toggleShowChangelog(); - }} - type="button" - > - {#if showChangelog === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - {/if} - - {$i18n.t('Chat')} - - - - - {$i18n.t('Title Auto-Generation')} - - - { - toggleTitleAutoGenerate(); - }} - type="button" - > - {#if titleAutoGenerate === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - {$i18n.t('Follow-Up Auto-Generation')} - - { - toggleAutoFollowUps(); - }} - type="button" - > - {#if autoFollowUps === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t('Chat Tags Auto-Generation')} - - - { - toggleAutoTags(); - }} - type="button" - > - {#if autoTags === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t('Detect Artifacts Automatically')} - - - { - toggleDetectArtifacts(); - }} - type="button" - > - {#if detectArtifacts === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t('Auto-Copy Response to Clipboard')} - - - { - toggleResponseAutoCopy(); - }} - type="button" - > - {#if responseAutoCopy === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t('Fade Effect for Streaming Text')} - - - { - toggleChatFadeStreamingText(); - }} - type="button" - > - {#if chatFadeStreamingText === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t('Keep Follow-Up Prompts in Chat')} - - - { - toggleKeepFollowUpPrompts(); - }} - type="button" - > - {#if keepFollowUpPrompts === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t('Insert Follow-Up Prompt to Input')} - - - { - toggleInsertFollowUpPrompt(); - }} - type="button" - > - {#if insertFollowUpPrompt === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t('Rich Text Input for Chat')} - - - { - toggleRichTextInput(); - }} - type="button" - > - {#if richTextInput === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - {#if richTextInput} - - - - {$i18n.t('Insert Prompt as Rich Text')} - - - { - toggleInsertPromptAsRichText(); - }} - type="button" - > - {#if insertPromptAsRichText === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - {#if $config?.features?.enable_autocomplete_generation} - - - - {$i18n.t('Prompt Autocompletion')} - - - { - togglePromptAutocomplete(); - }} - type="button" - > - {#if promptAutocomplete === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - {/if} - {/if} - - - - - {$i18n.t('Paste Large Text as File')} - - - { - toggleLargeTextAsFile(); - }} - type="button" - > - {#if largeTextAsFile === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t('Copy Formatted Text')} - - - { - toggleCopyFormatted(); - }} - type="button" - > - {#if copyFormatted === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t('Always Collapse Code Blocks')} - - - { - toggleCollapseCodeBlocks(); - }} - type="button" - > - {#if collapseCodeBlocks === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - - - - {$i18n.t('Always Expand Details')} - - - { - toggleExpandDetails(); - }} - type="button" - > - {#if expandDetails === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} @@ -1011,7 +498,7 @@ { if (backgroundImageUrl !== null) { @@ -1023,85 +510,358 @@ }} type="button" > - {#if backgroundImageUrl !== null} - {$i18n.t('Reset')} - {:else} - {$i18n.t('Upload')} - {/if} - - - - - - - {$i18n.t('Allow User Location')} - - { - toggleUserLocation(); - }} - type="button" - > - {#if userLocation === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} + {backgroundImageUrl !== null ? $i18n.t('Reset') : $i18n.t('Upload')} - - {$i18n.t('Haptic Feedback')} ({$i18n.t('Android')}) + + {$i18n.t('Chat Bubble UI')} + + + + { + saveSettings({ chatBubble }); + }} + /> + + + + + {#if !$settings.chatBubble} + + + + {$i18n.t('Display the username instead of You in the Chat')} + + + + { + saveSettings({ showUsername }); + }} + /> + + + + {/if} + + + + + {$i18n.t('Widescreen Mode')} + + + + { + saveSettings({ widescreenMode }); + }} + /> + + + + + + + + {$i18n.t('Fade Effect for Streaming Text')} + + + + { + saveSettings({ chatFadeStreamingText }); + }} + /> + + + + + + + + {$i18n.t('Title Auto-Generation')} + + + + { + toggleTitleAutoGenerate(); + }} + /> + + + + + + + + {$i18n.t('Follow-Up Auto-Generation')} + + + + { + saveSettings({ autoFollowUps }); + }} + /> + + + + + + + + {$i18n.t('Chat Tags Auto-Generation')} + + + + { + saveSettings({ autoTags }); + }} + /> + + + + + + + + {$i18n.t('Auto-Copy Response to Clipboard')} + + + + { + toggleResponseAutoCopy(); + }} + /> + + + + + + + + {$i18n.t('Keep Follow-Up Prompts in Chat')} + + + + { + saveSettings({ keepFollowUpPrompts }); + }} + /> + + + + + + + + {$i18n.t('Insert Follow-Up Prompt to Input')} + + + + { + saveSettings({ insertFollowUpPrompt }); + }} + /> + + + + + + + + {$i18n.t('Always Collapse Code Blocks')} + + + + { + saveSettings({ collapseCodeBlocks }); + }} + /> + + + + + + + + {$i18n.t('Always Expand Details')} + + + + { + saveSettings({ expandDetails }); + }} + /> + + + + + + + + {$i18n.t('Display Multi-model Responses in Tabs')} + + + + { + saveSettings({ displayMultiModelResponsesInTabs }); + }} + /> + + + + + + + + {$i18n.t('Scroll On Branch Change')} + + + + { + saveSettings({ scrollOnBranchChange }); + }} + /> + + + + + + + + {$i18n.t('Stylized PDF Export')} + + + + { + saveSettings({ stylizedPdfExport }); + }} + /> + + + + + + + + {$i18n.t('Floating Quick Actions')} + + + + {#if showFloatingActionButtons} + { + showManageFloatingActionButtonsModal = true; + }} + > + {$i18n.t('Manage')} + + {/if} + + { + saveSettings({ showFloatingActionButtons }); + }} + /> + + + + + + + + {$i18n.t('Web Search in Chat')} { - toggleHapticFeedback(); + toggleWebSearch(); }} type="button" > - {#if hapticFeedback === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} + {webSearch === 'always' ? $i18n.t('Always') : $i18n.t('Default')} - + {$i18n.t('Input')} - + {$i18n.t('Enter Key Behavior')} @@ -1113,58 +873,132 @@ }} type="button" > - {#if ctrlEnterToSend === true} - {$i18n.t('Ctrl+Enter to Send')} - {:else} - {$i18n.t('Enter to Send')} - {/if} + {ctrlEnterToSend === true + ? $i18n.t('Ctrl+Enter to Send') + : $i18n.t('Enter to Send')} - - {$i18n.t('Scroll On Branch Change')} + + {$i18n.t('Rich Text Input for Chat')} - { - togglesScrollOnBranchChange(); - }} - type="button" - > - {#if scrollOnBranchChange === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - + + { + saveSettings({ richTextInput }); + }} + /> + + {#if richTextInput} + + + + {$i18n.t('Show Formatting Toolbar')} + + + + { + saveSettings({ showFormattingToolbar }); + }} + /> + + + + + + + + {$i18n.t('Insert Prompt as Rich Text')} + + + + { + saveSettings({ insertPromptAsRichText }); + }} + /> + + + + + {#if $config?.features?.enable_autocomplete_generation} + + + + {$i18n.t('Prompt Autocompletion')} + + + + { + saveSettings({ promptAutocomplete }); + }} + /> + + + + {/if} + {/if} + - - {$i18n.t('Web Search in Chat')} + + {$i18n.t('Paste Large Text as File')} - { - toggleWebSearch(); - }} - type="button" - > - {#if webSearch === 'always'} - {$i18n.t('Always')} - {:else} - {$i18n.t('Default')} - {/if} - + + { + saveSettings({ largeTextAsFile }); + }} + /> + + + + + {$i18n.t('Artifacts')} + + + + + {$i18n.t('Detect Artifacts Automatically')} + + + + { + saveSettings({ detectArtifacts }); + }} + /> + @@ -1174,20 +1008,16 @@ {$i18n.t('iframe Sandbox Allow Same Origin')} - { - toggleIframeSandboxAllowSameOrigin(); - }} - type="button" - > - {#if iframeSandboxAllowSameOrigin === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - + + { + saveSettings({ iframeSandboxAllowSameOrigin }); + }} + /> + @@ -1197,66 +1027,37 @@ {$i18n.t('iframe Sandbox Allow Forms')} - { - toggleIframeSandboxAllowForms(); - }} - type="button" - > - {#if iframeSandboxAllowForms === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - + + { + saveSettings({ iframeSandboxAllowForms }); + }} + /> + + {$i18n.t('Voice')} + - - {$i18n.t('Stylized PDF Export')} + + {$i18n.t('Allow Voice Interruption in Call')} - { - toggleStylizedPdfExport(); - }} - type="button" - > - {#if stylizedPdfExport === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - - - - - {$i18n.t('Voice')} - - - - {$i18n.t('Allow Voice Interruption in Call')} - - { - toggleVoiceInterruption(); - }} - type="button" - > - {#if voiceInterruption === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - + + { + saveSettings({ voiceInterruption }); + }} + /> + @@ -1266,24 +1067,20 @@ {$i18n.t('Display Emoji in Call')} - { - toggleEmojiInCall(); - }} - type="button" - > - {#if showEmojiInCall === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - + + { + saveSettings({ showEmojiInCall }); + }} + /> + - {$i18n.t('File')} + {$i18n.t('File')} @@ -1291,20 +1088,16 @@ {$i18n.t('Image Compression')} - { - toggleImageCompression(); - }} - type="button" - > - {#if imageCompression === true} - {$i18n.t('On')} - {:else} - {$i18n.t('Off')} - {/if} - + + { + saveSettings({ imageCompression }); + }} + /> + @@ -1315,13 +1108,14 @@ {$i18n.t('Image Max Compression Size')} - + {$i18n.t('Image Max Compression Size width')} + + + + + {$i18n.t('Compress Images in Channels')} + + + + { + saveSettings({ imageCompressionInChannels }); + }} + /> + + + {/if} diff --git a/src/lib/components/chat/Settings/Interface/ManageFloatingActionButtonsModal.svelte b/src/lib/components/chat/Settings/Interface/ManageFloatingActionButtonsModal.svelte new file mode 100644 index 0000000000..9fa6e89b7e --- /dev/null +++ b/src/lib/components/chat/Settings/Interface/ManageFloatingActionButtonsModal.svelte @@ -0,0 +1,180 @@ + + + + + + + {$i18n.t('Quick Actions')} + + { + show = false; + }} + > + + + + + + + { + e.preventDefault(); + submitHandler(); + }} + > + + + Actions + + + { + if (floatingActionButtons === null) { + floatingActionButtons = [ + { + id: 'ask', + label: $i18n.t('Ask'), + input: true, + prompt: `{{SELECTED_CONTENT}}\n\n\n{{INPUT_CONTENT}}` + }, + { + id: 'explain', + label: $i18n.t('Explain'), + input: false, + prompt: `{{SELECTED_CONTENT}}\n\n\n${$i18n.t('Explain')}` + } + ]; + } else { + floatingActionButtons = null; + } + }} + > + {#if floatingActionButtons === null} + {$i18n.t('Default')} + {:else} + {$i18n.t('Custom')} + {/if} + + + {#if floatingActionButtons !== null} + { + let id = `new-button`; + let idx = 0; + + while (floatingActionButtons.some((b) => b.id === id)) { + idx++; + id = `new-button-${idx}`; + } + + floatingActionButtons = [ + ...floatingActionButtons, + { + id: id, + label: `${$i18n.t('New Button')}`, + input: true, + prompt: `{{CONTENT}}\n\n\n{{INPUT_CONTENT}}` + } + ]; + }} + > + + + {/if} + + + + {#if floatingActionButtons === null || floatingActionButtons.length === 0} + + {$i18n.t('Default action buttons will be used.')} + + {:else} + {#each floatingActionButtons as button, buttonIdx} + + + + + + + + + + + { + floatingActionButtons = floatingActionButtons.filter( + (b) => b.id !== button.id + ); + }} + type="button" + > + + + + + + {/each} + {/if} + + + + + {$i18n.t('Save')} + + + + + + + diff --git a/src/lib/components/chat/Settings/Personalization/ManageModal.svelte b/src/lib/components/chat/Settings/Personalization/ManageModal.svelte index 44c2724572..4fb1b9d2b2 100644 --- a/src/lib/components/chat/Settings/Personalization/ManageModal.svelte +++ b/src/lib/components/chat/Settings/Personalization/ManageModal.svelte @@ -75,7 +75,7 @@ {#if memories.length > 0} @@ -184,13 +184,13 @@ { showAddMemoryModal = true; }}>{$i18n.t('Add Memory')} { if (memories.length > 0) { showClearConfirmDialog = true; diff --git a/src/lib/components/common/Banner.svelte b/src/lib/components/common/Banner.svelte index 3e96390fb0..765b57cc36 100644 --- a/src/lib/components/common/Banner.svelte +++ b/src/lib/components/common/Banner.svelte @@ -1,12 +1,13 @@ -{#if showFormattingButtons} +{#if showFormattingToolbar} diff --git a/src/lib/components/common/RichTextInput/FormattingButtons.svelte b/src/lib/components/common/RichTextInput/FormattingButtons.svelte index 47c6e64e44..1ea2003c0e 100644 --- a/src/lib/components/common/RichTextInput/FormattingButtons.svelte +++ b/src/lib/components/common/RichTextInput/FormattingButtons.svelte @@ -18,7 +18,6 @@ import Tooltip from '../Tooltip.svelte'; import CheckBox from '$lib/components/icons/CheckBox.svelte'; import ArrowLeftTag from '$lib/components/icons/ArrowLeftTag.svelte'; - import ArrowRightTag from '$lib/components/icons/ArrowRightTag.svelte'; - + {/if} diff --git a/src/lib/components/common/SensitiveInput.svelte b/src/lib/components/common/SensitiveInput.svelte index 9f7802bd60..3c9c60529e 100644 --- a/src/lib/components/common/SensitiveInput.svelte +++ b/src/lib/components/common/SensitiveInput.svelte @@ -2,8 +2,10 @@ const i18n = getContext('i18n'); import { getContext } from 'svelte'; import { settings } from '$lib/stores'; + export let id = 'password-input'; export let value: string = ''; export let placeholder = ''; + export let type = 'text'; export let required = true; export let readOnly = false; export let outerClassName = 'flex flex-1 bg-transparent'; @@ -14,16 +16,19 @@ - {placeholder || $i18n.t('Password')} + {placeholder || $i18n.t('Password')} { + value = e.target.value; + }} autocomplete="off" - type="text" /> - import { createEventDispatcher, tick } from 'svelte'; + import { createEventDispatcher, tick, getContext } from 'svelte'; import { Switch } from 'bits-ui'; import { settings } from '$lib/stores'; + import Tooltip from './Tooltip.svelte'; export let state = true; export let id = ''; + export let ariaLabelledbyId = ''; + export let tooltip = false; + const i18n = getContext('i18n'); const dispatch = createEventDispatcher(); $: dispatch('change', state); - - - + + + + diff --git a/src/lib/components/common/Textarea.svelte b/src/lib/components/common/Textarea.svelte index 9a1a182398..8ed4c900e1 100644 --- a/src/lib/components/common/Textarea.svelte +++ b/src/lib/components/common/Textarea.svelte @@ -7,6 +7,7 @@ export let minSize = null; export let maxSize = null; export let required = false; + export let readonly = false; export let className = 'w-full rounded-lg px-3.5 py-2 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden h-full'; @@ -54,6 +55,7 @@ style="field-sizing: content;" {rows} {required} + {readonly} on:input={(e) => { resize(); }} diff --git a/src/lib/components/common/Tooltip.svelte b/src/lib/components/common/Tooltip.svelte index d83830d17a..738d06b9aa 100644 --- a/src/lib/components/common/Tooltip.svelte +++ b/src/lib/components/common/Tooltip.svelte @@ -5,6 +5,8 @@ import tippy from 'tippy.js'; + export let elementId = ''; + export let placement = 'top'; export let content = `I'm a tooltip!`; export let touch = true; @@ -13,24 +15,36 @@ export let offset = [0, 4]; export let allowHTML = true; export let tippyOptions = {}; + export let interactive = false; let tooltipElement; let tooltipInstance; - $: if (tooltipElement && content) { - if (tooltipInstance) { - tooltipInstance.setContent(DOMPurify.sanitize(content)); + $: if (tooltipElement && (content || elementId)) { + let tooltipContent = null; + + if (elementId) { + tooltipContent = document.getElementById(`${elementId}`); } else { - tooltipInstance = tippy(tooltipElement, { - content: DOMPurify.sanitize(content), - placement: placement, - allowHTML: allowHTML, - touch: touch, - ...(theme !== '' ? { theme } : { theme: 'dark' }), - arrow: false, - offset: offset, - ...tippyOptions - }); + tooltipContent = DOMPurify.sanitize(content); + } + + if (tooltipInstance) { + tooltipInstance.setContent(tooltipContent); + } else { + if (content) { + tooltipInstance = tippy(tooltipElement, { + content: tooltipContent, + placement: placement, + allowHTML: allowHTML, + touch: touch, + ...(theme !== '' ? { theme } : { theme: 'dark' }), + arrow: false, + offset: offset, + interactive: interactive, + ...tippyOptions + }); + } } } else if (tooltipInstance && content === '') { if (tooltipInstance) { @@ -48,3 +62,5 @@ + + diff --git a/src/lib/components/icons/ChatBubbleOval.svelte b/src/lib/components/icons/ChatBubbleOval.svelte index 4edbbb6140..81c8b2a6c1 100644 --- a/src/lib/components/icons/ChatBubbleOval.svelte +++ b/src/lib/components/icons/ChatBubbleOval.svelte @@ -9,6 +9,7 @@ viewBox="0 0 24 24" stroke-width={strokeWidth} stroke="currentColor" + aria-hidden="true" class={className} > - export let className = 'size-4'; - export let strokeWidth = '1.5'; - - - - - diff --git a/src/lib/components/icons/Eye.svelte b/src/lib/components/icons/Eye.svelte index 5af95a9e7d..66c2b2bfaa 100644 --- a/src/lib/components/icons/Eye.svelte +++ b/src/lib/components/icons/Eye.svelte @@ -10,6 +10,7 @@ stroke-width={strokeWidth} stroke="currentColor" class={className} + aria-hidden="true" > + export let className = 'size-4'; + export let strokeWidth = '1.5'; + + + diff --git a/src/lib/components/icons/LineSpace.svelte b/src/lib/components/icons/LineSpace.svelte new file mode 100644 index 0000000000..c8a62274b6 --- /dev/null +++ b/src/lib/components/icons/LineSpace.svelte @@ -0,0 +1,22 @@ + + + diff --git a/src/lib/components/icons/LineSpaceSmaller.svelte b/src/lib/components/icons/LineSpaceSmaller.svelte new file mode 100644 index 0000000000..b371d22570 --- /dev/null +++ b/src/lib/components/icons/LineSpaceSmaller.svelte @@ -0,0 +1,24 @@ + + + + + + diff --git a/src/lib/components/icons/Link.svelte b/src/lib/components/icons/Link.svelte index 9f1a723110..7e56ab0dd8 100644 --- a/src/lib/components/icons/Link.svelte +++ b/src/lib/components/icons/Link.svelte @@ -7,6 +7,7 @@ fill-rule="evenodd" d="M8.914 6.025a.75.75 0 0 1 1.06 0 3.5 3.5 0 0 1 0 4.95l-2 2a3.5 3.5 0 0 1-5.396-4.402.75.75 0 0 1 1.251.827 2 2 0 0 0 3.085 2.514l2-2a2 2 0 0 0 0-2.828.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" + aria-hidden="true" /> - export let className = 'size-4'; + export let className = 'w-4 h-4'; export let strokeWidth = '1.5'; diff --git a/src/lib/components/icons/PencilSquare.svelte b/src/lib/components/icons/PencilSquare.svelte index d1e02b1ad7..6102fa6ac0 100644 --- a/src/lib/components/icons/PencilSquare.svelte +++ b/src/lib/components/icons/PencilSquare.svelte @@ -10,6 +10,7 @@ stroke-width={strokeWidth} stroke="currentColor" class={className} + aria-hidden="true" > + export let className = 'size-4'; + export let strokeWidth = '1.5'; + + + diff --git a/src/lib/components/icons/Search.svelte b/src/lib/components/icons/Search.svelte index c2dc55845d..2953f00990 100644 --- a/src/lib/components/icons/Search.svelte +++ b/src/lib/components/icons/Search.svelte @@ -9,6 +9,7 @@ viewBox="0 0 24 24" stroke-width={strokeWidth} stroke="currentColor" + aria-hidden="true" class={className} > - export let className = 'size-4'; + export let className = 'size-5'; export let strokeWidth = '1.5'; @@ -10,11 +10,17 @@ stroke-width={strokeWidth} stroke="currentColor" class={className} - > diff --git a/src/lib/components/icons/Tag.svelte b/src/lib/components/icons/Tag.svelte new file mode 100644 index 0000000000..2eca7f85b2 --- /dev/null +++ b/src/lib/components/icons/Tag.svelte @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/src/lib/components/icons/Voice.svelte b/src/lib/components/icons/Voice.svelte new file mode 100644 index 0000000000..43ee47aa56 --- /dev/null +++ b/src/lib/components/icons/Voice.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/components/layout/Navbar.svelte b/src/lib/components/layout/Navbar.svelte index 586dbcaf1c..27e19aaf04 100644 --- a/src/lib/components/layout/Navbar.svelte +++ b/src/lib/components/layout/Navbar.svelte @@ -19,14 +19,12 @@ import ModelSelector from '../chat/ModelSelector.svelte'; import Tooltip from '../common/Tooltip.svelte'; import Menu from './Navbar/Menu.svelte'; - import { page } from '$app/stores'; import UserMenu from './Sidebar/UserMenu.svelte'; - import MenuLines from '../icons/MenuLines.svelte'; import AdjustmentsHorizontal from '../icons/AdjustmentsHorizontal.svelte'; import Map from '../icons/Map.svelte'; - import { stringify } from 'postcss'; import PencilSquare from '../icons/PencilSquare.svelte'; import Plus from '../icons/Plus.svelte'; + import Sidebar from '../icons/Sidebar.svelte'; const i18n = getContext('i18n'); @@ -51,24 +49,30 @@ - - { - showSidebar.set(!$showSidebar); - }} - aria-label="Toggle Sidebar" + {#if $mobile} + - - - - - + + { + showSidebar.set(!$showSidebar); + }} + > + + + + + + + {/if} {}} + sendMessage={() => {}} continueResponse={() => {}} regenerateResponse={() => {}} /> diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index c2b685dcc3..3718ffdbb7 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -10,6 +10,7 @@ showSettings, chatId, tags, + folders as _folders, showSidebar, showSearch, mobile, @@ -23,7 +24,8 @@ config, isApp, models, - selectedFolder + selectedFolder, + WEBUI_NAME } from '$lib/stores'; import { onMount, getContext, tick, onDestroy } from 'svelte'; @@ -46,19 +48,20 @@ import ChatItem from './Sidebar/ChatItem.svelte'; import Spinner from '../common/Spinner.svelte'; import Loader from '../common/Loader.svelte'; - import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte'; import Folder from '../common/Folder.svelte'; - import Plus from '../icons/Plus.svelte'; import Tooltip from '../common/Tooltip.svelte'; import Folders from './Sidebar/Folders.svelte'; import { getChannels, createNewChannel } from '$lib/apis/channels'; import ChannelModal from './Sidebar/ChannelModal.svelte'; import ChannelItem from './Sidebar/ChannelItem.svelte'; import PencilSquare from '../icons/PencilSquare.svelte'; - import Home from '../icons/Home.svelte'; import Search from '../icons/Search.svelte'; import SearchModal from './SearchModal.svelte'; import FolderModal from './Sidebar/Folders/FolderModal.svelte'; + import Sidebar from '../icons/Sidebar.svelte'; + import PinnedModelList from './Sidebar/PinnedModelList.svelte'; + import Note from '../icons/Note.svelte'; + import { slide } from 'svelte/transition'; const BREAKPOINT = 768; @@ -66,7 +69,6 @@ let shiftKey = false; let selectedChatId = null; - let showDropdown = false; let showPinnedChat = true; let showCreateChannel = false; @@ -84,6 +86,7 @@ toast.error(`${error}`); return []; }); + _folders.set(folderList); folders = {}; @@ -348,7 +351,7 @@ }); showSidebar.set(!$mobile ? localStorage.sidebar === 'true' : false); - showSidebar.subscribe((value) => { + showSidebar.subscribe(async (value) => { localStorage.sidebar = value; // nav element is not available on the first render @@ -365,6 +368,11 @@ navElement.style['-webkit-app-region'] = 'drag'; } } + + if (!value) { + await initChannels(); + await initChatList(); + } }); chats.subscribe((value) => { @@ -406,6 +414,34 @@ dropZone?.removeEventListener('drop', onDrop); dropZone?.removeEventListener('dragleave', onDragLeave); }); + + const newChatHandler = async () => { + selectedChatId = null; + selectedFolder.set(null); + + if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) { + await temporaryChatEnabled.set(true); + } else { + await temporaryChatEnabled.set(false); + } + + setTimeout(() => { + if ($mobile) { + showSidebar.set(false); + } + }, 0); + }; + + const itemClickHandler = async () => { + selectedChatId = null; + chatId.set(''); + + if ($mobile) { + showSidebar.set(false); + } + + await tick(); + }; - + { + goto('/'); + newChatHandler(); + }} +/> + +{#if !$mobile && !$showSidebar} - - { - showSidebar.set(!$showSidebar); - }} - > - - { + showSidebar.set(!$showSidebar); + }} + > + + + - - - - - - { - selectedChatId = null; - selectedFolder.set(null); - - if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) { - await temporaryChatEnabled.set(true); - } else { - await temporaryChatEnabled.set(false); - } - - setTimeout(() => { - if ($mobile) { - showSidebar.set(false); - } - }, 0); - }} - > - - - - - - {$i18n.t('New Chat')} - - - - - - - - - - - - - { - showSearch.set(true); - }} - draggable="false" - > - - - - - - {$i18n.t('Search')} - - - - - {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))} - - { - selectedChatId = null; - chatId.set(''); - - if ($mobile) { - showSidebar.set(false); - } - }} - draggable="false" - > - - - + - - - - {$i18n.t('Notes')} - - + + + + - {/if} - {#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools} - - { - selectedChatId = null; - chatId.set(''); + + + + { + e.stopImmediatePropagation(); + e.preventDefault(); - if ($mobile) { - showSidebar.set(false); - } - }} - draggable="false" - > - - - - - - - - {$i18n.t('Workspace')} - - - - {/if} - - - {#if ($models ?? []).length > 0 && ($settings?.pinnedModels ?? []).length > 0} - - {#each $settings.pinnedModels as modelId (modelId)} - {@const model = $models.find((model) => model.id === modelId)} - {#if model} - - { - selectedChatId = null; - chatId.set(''); - - if ($mobile) { - showSidebar.set(false); - } - }} - draggable="false" - > - - - - - - - {model?.name ?? modelId} - - - + + - {/if} - {/each} + + - {/if} - {#if $config?.features?.enable_channels && ($user?.role === 'admin' || $channels.length > 0)} + + + { + e.stopImmediatePropagation(); + e.preventDefault(); + + showSearch.set(true); + }} + draggable="false" + > + + + + + + + + {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))} + + + { + e.stopImmediatePropagation(); + e.preventDefault(); + + goto('/notes'); + itemClickHandler(); + }} + draggable="false" + > + + + + + + + {/if} + + {#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools} + + + { + e.stopImmediatePropagation(); + e.preventDefault(); + + goto('/workspace'); + itemClickHandler(); + }} + draggable="false" + > + + + + + + + + + {/if} + + + + + + + {#if $user !== undefined && $user !== null} + { + if (e.detail === 'archived-chat') { + showArchivedChats.set(true); + } + }} + > + + + + + + + {/if} + + + + +{/if} + +{#if $showSidebar} + + + + + + + + + + {$WEBUI_NAME} + + + + { + showSidebar.set(!$showSidebar); + }} + > + + + + + + + + + + + + + + + + {$i18n.t('New Chat')} + + + + + + { + showSearch.set(true); + }} + draggable="false" + > + + + + + + {$i18n.t('Search')} + + + + + {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))} + + + + + + + + {$i18n.t('Notes')} + + + + {/if} + + {#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools} + + + + + + + + + + {$i18n.t('Workspace')} + + + + {/if} + + + + {#if ($models ?? []).length > 0 && ($settings?.pinnedModels ?? []).length > 0} + + {/if} + + {#if $config?.features?.enable_channels && ($user?.role === 'admin' || $channels.length > 0)} + { + if ($user?.role === 'admin') { + await tick(); + + setTimeout(() => { + showCreateChannel = true; + }, 0); + } + }} + onAddLabel={$i18n.t('Create Channel')} + > + {#each $channels as channel} + { + await initChannels(); + }} + /> + {/each} + + {/if} + { - if ($user?.role === 'admin') { - await tick(); - - setTimeout(() => { - showCreateChannel = true; - }, 0); - } + name={$i18n.t('Chats')} + onAdd={() => { + showCreateFolderModal = true; }} - onAddLabel={$i18n.t('Create Channel')} - > - {#each $channels as channel} - { - await initChannels(); - }} - /> - {/each} - - {/if} + onAddLabel={$i18n.t('New Folder')} + on:change={async (e) => { + selectedFolder.set(null); + await goto('/'); + }} + on:import={(e) => { + importChatHandler(e.detail); + }} + on:drop={async (e) => { + const { type, id, item } = e.detail; - { - showCreateFolderModal = true; - }} - onAddLabel={$i18n.t('New Folder')} - on:change={async (e) => { - selectedFolder.set(null); - await goto('/'); - }} - on:import={(e) => { - importChatHandler(e.detail); - }} - on:drop={async (e) => { - const { type, id, item } = e.detail; - - if (type === 'chat') { - let chat = await getChatById(localStorage.token, id).catch((error) => { - return null; - }); - if (!chat && item) { - chat = await importChat( - localStorage.token, - item.chat, - item?.meta ?? {}, - false, - null, - item?.created_at ?? null, - item?.updated_at ?? null - ); - } - - if (chat) { - console.log(chat); - if (chat.folder_id) { - const res = await updateChatFolderIdById(localStorage.token, chat.id, null).catch( - (error) => { - toast.error(`${error}`); - return null; - } + if (type === 'chat') { + let chat = await getChatById(localStorage.token, id).catch((error) => { + return null; + }); + if (!chat && item) { + chat = await importChat( + localStorage.token, + item.chat, + item?.meta ?? {}, + false, + null, + item?.created_at ?? null, + item?.updated_at ?? null ); } - if (chat.pinned) { - const res = await toggleChatPinnedStatusById(localStorage.token, chat.id); + if (chat) { + console.log(chat); + if (chat.folder_id) { + const res = await updateChatFolderIdById(localStorage.token, chat.id, null).catch( + (error) => { + toast.error(`${error}`); + return null; + } + ); + } + + if (chat.pinned) { + const res = await toggleChatPinnedStatusById(localStorage.token, chat.id); + } + + initChatList(); + } + } else if (type === 'folder') { + if (folders[id].parent_id === null) { + return; } - initChatList(); - } - } else if (type === 'folder') { - if (folders[id].parent_id === null) { - return; - } + const res = await updateFolderParentIdById(localStorage.token, id, null).catch( + (error) => { + toast.error(`${error}`); + return null; + } + ); - const res = await updateFolderParentIdById(localStorage.token, id, null).catch( - (error) => { - toast.error(`${error}`); - return null; + if (res) { + await initFolders(); } - ); - - if (res) { - await initFolders(); } - } - }} - > - {#if $pinnedChats.length > 0} - - { - localStorage.setItem('showPinnedChat', e.detail); - console.log(e.detail); + }} + > + {#if $pinnedChats.length > 0} + + { + localStorage.setItem('showPinnedChat', e.detail); + console.log(e.detail); + }} + on:import={(e) => { + importChatHandler(e.detail, true); + }} + on:drop={async (e) => { + const { type, id, item } = e.detail; + + if (type === 'chat') { + let chat = await getChatById(localStorage.token, id).catch((error) => { + return null; + }); + if (!chat && item) { + chat = await importChat( + localStorage.token, + item.chat, + item?.meta ?? {}, + false, + null, + item?.created_at ?? null, + item?.updated_at ?? null + ); + } + + if (chat) { + console.log(chat); + if (chat.folder_id) { + const res = await updateChatFolderIdById( + localStorage.token, + chat.id, + null + ).catch((error) => { + toast.error(`${error}`); + return null; + }); + } + + if (!chat.pinned) { + const res = await toggleChatPinnedStatusById(localStorage.token, chat.id); + } + + initChatList(); + } + } + }} + name={$i18n.t('Pinned')} + > + + {#each $pinnedChats as chat, idx (`pinned-chat-${chat?.id ?? idx}`)} + { + selectedChatId = chat.id; + }} + on:unselect={() => { + selectedChatId = null; + }} + on:change={async () => { + initChatList(); + }} + on:tag={(e) => { + const { type, name } = e.detail; + tagEventHandler(type, name, chat.id); + }} + /> + {/each} + + + + {/if} + + {#if folders} + { + selectedFolder.set(null); + initChatList(); + }} + on:update={() => { + initChatList(); }} on:import={(e) => { - importChatHandler(e.detail, true); + const { folderId, items } = e.detail; + importChatHandler(items, false, folderId); }} - on:drop={async (e) => { - const { type, id, item } = e.detail; - - if (type === 'chat') { - let chat = await getChatById(localStorage.token, id).catch((error) => { - return null; - }); - if (!chat && item) { - chat = await importChat( - localStorage.token, - item.chat, - item?.meta ?? {}, - false, - null, - item?.created_at ?? null, - item?.updated_at ?? null - ); - } - - if (chat) { - console.log(chat); - if (chat.folder_id) { - const res = await updateChatFolderIdById( - localStorage.token, - chat.id, - null - ).catch((error) => { - toast.error(`${error}`); - return null; - }); - } - - if (!chat.pinned) { - const res = await toggleChatPinnedStatusById(localStorage.token, chat.id); - } - - initChatList(); - } - } + on:change={async () => { + initChatList(); }} - name={$i18n.t('Pinned')} - > - - {#each $pinnedChats as chat, idx (`pinned-chat-${chat?.id ?? idx}`)} + /> + {/if} + + + + {#if $chats} + {#each $chats as chat, idx (`chat-${chat?.id ?? idx}`)} + {#if idx === 0 || (idx > 0 && chat.time_range !== $chats[idx - 1].time_range)} + + {$i18n.t(chat.time_range)} + + + {/if} + {/each} - - - - {/if} - {#if folders} - { - selectedFolder.set(null); - initChatList(); - }} - on:update={() => { - initChatList(); - }} - on:import={(e) => { - const { folderId, items } = e.detail; - importChatHandler(items, false, folderId); - }} - on:change={async () => { - initChatList(); - }} - /> - {/if} - - - - {#if $chats} - {#each $chats as chat, idx (`chat-${chat?.id ?? idx}`)} - {#if idx === 0 || (idx > 0 && chat.time_range !== $chats[idx - 1].time_range)} - { + if (!chatListLoading) { + loadMoreChats(); + } + }} > - {$i18n.t(chat.time_range)} - - + + + Loading... + + {/if} - - { - selectedChatId = chat.id; - }} - on:unselect={() => { - selectedChatId = null; - }} - on:change={async () => { - initChatList(); - }} - on:tag={(e) => { - const { type, name } = e.detail; - tagEventHandler(type, name, chat.id); - }} - /> - {/each} - - {#if $scrollPaginationEnabled && !allChatsLoaded} - { - if (!chatListLoading) { - loadMoreChats(); - } - }} + {:else} + - - - Loading... - - + + Loading... + {/if} - {:else} - - - Loading... - - {/if} + - - - + + - - - {#if $user !== undefined && $user !== null} - { - if (e.detail === 'archived-chat') { - showArchivedChats.set(true); - } - }} - > - { - showDropdown = !showDropdown; + + + {#if $user !== undefined && $user !== null} + { + if (e.detail === 'archived-chat') { + showArchivedChats.set(true); + } }} > - - + + + + + {$user?.name} - {$user?.name} - - - {/if} + + {/if} + - +{/if}
{JSON.stringify( - document.metadata.parameters, - null, - 2 - )}