diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index a8f9266e9d..7a5dc651c4 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -141,6 +141,9 @@ jobs: platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + - name: Delete huge unnecessary tools folder + run: rm -rf /opt/hostedtoolcache + - name: Checkout repository uses: actions/checkout@v5 @@ -243,6 +246,9 @@ jobs: platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + - name: Delete huge unnecessary tools folder + run: rm -rf /opt/hostedtoolcache + - name: Checkout repository uses: actions/checkout@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d119a1386..72ffba1114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,104 @@ 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.38] - 2025-11-24 + +### Fixed + +- 🔍 Hybrid search now works reliably after recent changes. +- 🛠️ Tool server saving now handles errors gracefully, preventing failed saves from impacting the UI. +- 🔐 SSO/OIDC code fixed to improve login reliability and better handle edge cases. + +## [0.6.37] - 2025-11-24 + +### Added + +- 🔐 Granular sharing permissions are now available with two-tiered control separating group sharing from public sharing, allowing administrators to independently configure whether users can share workspace items with groups or make them publicly accessible, with separate permission toggles for models, knowledge bases, prompts, tools, and notes, configurable via "USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING", "USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING", and corresponding environment variables for other workspace item types, while groups can now be configured to opt-out of sharing via the "Allow Group Sharing" setting. [Commit](https://github.com/open-webui/open-webui/commit/7be750bcbb40da91912a0a66b7ab791effdcc3b6), [Commit](https://github.com/open-webui/open-webui/commit/f69e37a8507d6d57382d6670641b367f3127f90a) +- 🔐 Password policy enforcement is now available with configurable validation rules, allowing administrators to require specific password complexity requirements via "ENABLE_PASSWORD_VALIDATION" and "PASSWORD_VALIDATION_REGEX_PATTERN" environment variables, with default pattern requiring minimum 8 characters including uppercase, lowercase, digit, and special character. [#17794](https://github.com/open-webui/open-webui/pull/17794) +- 🔐 Granular import and export permissions are now available for workspace items, introducing six separate permission toggles for models, prompts, and tools that are disabled by default for enhanced security. [#19242](https://github.com/open-webui/open-webui/pull/19242) +- 👥 Default group assignment is now available for new users, allowing administrators to automatically assign newly registered users to a specified group for streamlined access control to models, prompts, and tools, particularly useful for organizations with group-based model access policies. [#19325](https://github.com/open-webui/open-webui/pull/19325), [#17842](https://github.com/open-webui/open-webui/issues/17842) +- 🔒 Password-based authentication can now be fully disabled via "ENABLE_PASSWORD_AUTH" environment variable, enforcing SSO-only authentication and preventing password login fallback when SSO is configured. [#19113](https://github.com/open-webui/open-webui/pull/19113) +- 🖼️ Large stream chunk handling was implemented to support models that generate images directly in their output responses, with configurable buffer size via "CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE" environment variable, resolving compatibility issues with models like Gemini 2.5 Flash Image. [#18884](https://github.com/open-webui/open-webui/pull/18884), [#17626](https://github.com/open-webui/open-webui/issues/17626) +- 🖼️ Streaming response middleware now handles images in delta updates with automatic base64 conversion, enabling proper display of images from models using the "choices[0].delta.images.image_url" format such as Gemini 2.5 Flash Image Preview on OpenRouter. [#19073](https://github.com/open-webui/open-webui/pull/19073), [#19019](https://github.com/open-webui/open-webui/issues/19019) +- 📈 Model list API performance was optimized by pre-fetching user group memberships and removing profile image URLs from response payloads, significantly reducing both database queries and payload size for instances with large model lists, with profile images now served dynamically via dedicated endpoints. [#19097](https://github.com/open-webui/open-webui/pull/19097), [#18950](https://github.com/open-webui/open-webui/issues/18950) +- ⏩ Batch file processing performance was improved by reducing database queries by 67% while ensuring data consistency between vector and relational databases. [#18953](https://github.com/open-webui/open-webui/pull/18953) +- 🚀 Chat import performance was dramatically improved by replacing individual per-chat API requests with a bulk import endpoint, reducing import time by up to 95% for large chat collections and providing user feedback via toast notifications displaying the number of successfully imported chats. [#17861](https://github.com/open-webui/open-webui/pull/17861) +- ⚡ Socket event broadcasting performance was optimized by implementing user-specific rooms, significantly reducing server overhead particularly for users with multiple concurrent sessions. [#18996](https://github.com/open-webui/open-webui/pull/18996) +- 🗄️ Weaviate is now supported as a vector database option, providing an additional choice for RAG document storage alongside existing ChromaDB, Milvus, Qdrant, and OpenSearch integrations. [#14747](https://github.com/open-webui/open-webui/pull/14747) +- 🗄️ PostgreSQL pgvector now supports HNSW index types and large dimensional embeddings exceeding 2000 dimensions through automatic halfvec type selection, with configurable index methods via "PGVECTOR_INDEX_METHOD", "PGVECTOR_HNSW_M", "PGVECTOR_HNSW_EF_CONSTRUCTION", and "PGVECTOR_IVFFLAT_LISTS" environment variables. [#19158](https://github.com/open-webui/open-webui/pull/19158), [#16890](https://github.com/open-webui/open-webui/issues/16890) +- 🔍 Azure AI Search is now supported as a web search provider, enabling integration with Azure's cognitive search services via "AZURE_AI_SEARCH_API_KEY", "AZURE_AI_SEARCH_ENDPOINT", and "AZURE_AI_SEARCH_INDEX_NAME" configuration. [#19104](https://github.com/open-webui/open-webui/pull/19104) +- ⚡ External embedding generation now processes API requests in parallel instead of sequential batches, reducing document processing time by 10-50x when using OpenAI, Azure OpenAI, or Ollama embedding providers, with large PDFs now processing in seconds instead of minutes. [#19296](https://github.com/open-webui/open-webui/pull/19296) +- 💨 Base64 image conversion is now available for markdown content in chat responses, automatically uploading embedded images exceeding 1KB and replacing them with file URLs to reduce payload size and resource consumption, configurable via "REPLACE_IMAGE_URLS_IN_CHAT_RESPONSE" environment variable. [#19076](https://github.com/open-webui/open-webui/pull/19076) +- 🎨 OpenAI image generation now supports additional API parameters including quality settings for GPT Image 1, configurable via "IMAGES_OPENAI_API_PARAMS" environment variable or through the admin interface, enabling cost-effective image generation with low, medium, or high quality options. [#19228](https://github.com/open-webui/open-webui/issues/19228) +- 🖼️ Image editing can now be independently enabled or disabled via admin settings, allowing administrators to control whether sequential image prompts trigger image editing or new image generation, configurable via "ENABLE_IMAGE_EDIT" environment variable. [#19284](https://github.com/open-webui/open-webui/issues/19284) +- 🔐 SSRF protection was implemented with a configurable URL blocklist that prevents access to cloud metadata endpoints and private networks, with default protections for AWS, Google Cloud, Azure, and Alibaba Cloud metadata services, customizable via "WEB_FETCH_FILTER_LIST" environment variable. [#19201](https://github.com/open-webui/open-webui/pull/19201) +- ⚡ Workspace models page now supports server-side pagination dramatically improving load times and usability for instances with large numbers of workspace models. +- 🔍 Hybrid search now indexes file metadata including filenames, titles, headings, sources, and snippets alongside document content, enabling keyword queries to surface documents where search terms appear only in metadata, configurable via "ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS" environment variable. [#19095](https://github.com/open-webui/open-webui/pull/19095) +- 📂 Knowledge base upload page now supports folder drag-and-drop with recursive directory handling, enabling batch uploads of entire directory structures instead of requiring individual file selection. [#19320](https://github.com/open-webui/open-webui/pull/19320) +- 🤖 Model cloning is now available in admin settings, allowing administrators to quickly create workspace models based on existing base models through a "Clone" option in the model dropdown menu. [#17937](https://github.com/open-webui/open-webui/pull/17937) +- 🎨 UI scale adjustment is now available in interface settings, allowing users to increase the size of the entire interface from 1.0x to 1.5x for improved accessibility and readability, particularly beneficial for users with visual impairments. [#19186](https://github.com/open-webui/open-webui/pull/19186) +- 📌 Default pinned models can now be configured by administrators for all new users, mirroring the behavior of default models where admin-configured defaults apply only to users who haven't customized their pinned models, configurable via "DEFAULT_PINNED_MODELS" environment variable. [#19273](https://github.com/open-webui/open-webui/pull/19273) +- 🎙️ Text-to-Speech and Speech-to-Text services now receive user information headers when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled, allowing external TTS and STT providers to implement user-specific personalization, rate limiting, and usage tracking. [#19323](https://github.com/open-webui/open-webui/pull/19323), [#19312](https://github.com/open-webui/open-webui/issues/19312) +- 🎙️ Voice mode now supports custom system prompts via "VOICE_MODE_PROMPT_TEMPLATE" configuration, allowing administrators to control response style and behavior for voice interactions. [#18607](https://github.com/open-webui/open-webui/pull/18607) +- 🔧 WebSocket and Redis configuration options are now available including debug logging controls, custom ping timeout and interval settings, and arbitrary Redis connection options via "WEBSOCKET_SERVER_LOGGING", "WEBSOCKET_SERVER_ENGINEIO_LOGGING", "WEBSOCKET_SERVER_PING_TIMEOUT", "WEBSOCKET_SERVER_PING_INTERVAL", and "WEBSOCKET_REDIS_OPTIONS" environment variables. [#19091](https://github.com/open-webui/open-webui/pull/19091) +- 🔧 MCP OAuth dynamic client registration now automatically detects and uses the appropriate token endpoint authentication method from server-supported options, enabling compatibility with OAuth servers that only support "client_secret_basic" instead of "client_secret_post". [#19193](https://github.com/open-webui/open-webui/issues/19193) +- 🔧 Custom headers can now be configured for remote MCP and OpenAPI tool server connections, enabling integration with services that require additional authentication headers. [#18918](https://github.com/open-webui/open-webui/issues/18918) +- 🔍 Perplexity Search now supports custom API endpoints via "PERPLEXITY_SEARCH_API_URL" configuration and automatically forwards user information headers to enable personalized search experiences. [#19147](https://github.com/open-webui/open-webui/pull/19147) +- 🔍 User information headers can now be optionally forwarded to external web search engines when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled. [#19043](https://github.com/open-webui/open-webui/pull/19043) +- 📊 Daily active user metric is now available for monitoring, tracking unique users active since midnight UTC via the "webui.users.active.today" Prometheus gauge. [#19236](https://github.com/open-webui/open-webui/pull/19236), [#19234](https://github.com/open-webui/open-webui/issues/19234) +- 📊 Audit log file path is now configurable via "AUDIT_LOGS_FILE_PATH" environment variable, enabling storage in separate volumes or custom locations. [#19173](https://github.com/open-webui/open-webui/pull/19173) +- 🎨 Sidebar collapse states for model lists and group information are now persistent across page refreshes, remembering user preferences through browser-based storage. [#19159](https://github.com/open-webui/open-webui/issues/19159) +- 🎨 Background image display was enhanced with semi-transparent overlays for navbar and sidebar, creating a seamless and visually cohesive design across the entire interface. [#19157](https://github.com/open-webui/open-webui/issues/19157) +- 📋 Tables in chat messages now include a copy button that appears on hover, enabling quick copying of table content alongside the existing CSV export functionality. [#19162](https://github.com/open-webui/open-webui/issues/19162) +- 📝 Notes can now be created directly via the "/notes/new" URL endpoint with optional title and content query parameters, enabling faster note creation through bookmarks and shortcuts. [#19195](https://github.com/open-webui/open-webui/issues/19195) +- 🏷️ Tag suggestions are now context-aware, displaying only relevant tags when creating or editing models versus chat conversations, preventing confusion between model and chat tags. [#19135](https://github.com/open-webui/open-webui/issues/19135) +- ✍️ Prompt autocompletion is now available independently of the rich text input setting, improving accessibility to the feature. [#19150](https://github.com/open-webui/open-webui/issues/19150) +- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security. +- 🌐 Translations for Simplified Chinese, Traditional Chinese, Portuguese (Brazil), Catalan, Spanish (Spain), Finnish, Irish, Farsi, Swedish, Danish, German, Korean, and Thai were improved and expanded. + +### Fixed + +- 🤖 Model update functionality now works correctly, resolving a database parameter binding error that prevented saving changes to model configurations via the Save & Update button. [#19335](https://github.com/open-webui/open-webui/issues/19335) +- 🖼️ Multiple input images for image editing and generation are now correctly passed as an array using the "image[]" parameter syntax, enabling proper multi-image reference functionality with models like GPT Image 1. [#19339](https://github.com/open-webui/open-webui/issues/19339) +- 📱 PWA installations on iOS now properly refresh after server container restarts, resolving freezing issues by automatically unregistering service workers when version or deployment changes are detected. [#19316](https://github.com/open-webui/open-webui/pull/19316) +- 🗄️ S3 Vectors collection detection now correctly handles buckets with more than 2000 indexes by using direct index lookup instead of paginated list scanning, improving performance by approximately 8x and enabling RAG queries to work reliably at scale. [#19238](https://github.com/open-webui/open-webui/pull/19238), [#19233](https://github.com/open-webui/open-webui/issues/19233) +- 📈 Feedback retrieval performance was optimized by eliminating N+1 query patterns through database joins, adding server-side pagination and sorting, significantly reducing database load for instances with large feedback datasets. [#17976](https://github.com/open-webui/open-webui/pull/17976) +- 🔍 Chat search now works correctly with PostgreSQL when chat data contains null bytes, with comprehensive sanitization preventing null bytes during data writes, cleaning existing data on read, and stripping null bytes during search queries to ensure reliable search functionality. [#15616](https://github.com/open-webui/open-webui/issues/15616) +- 🔍 Hybrid search with reranking now correctly handles attribute validation, preventing errors when collection results lack expected structure. [#19025](https://github.com/open-webui/open-webui/pull/19025), [#17046](https://github.com/open-webui/open-webui/issues/17046) +- 🔎 Reranking functionality now works correctly after recent refactoring, resolving crashes caused by incorrect function argument handling. [#19270](https://github.com/open-webui/open-webui/pull/19270) +- 🤖 Azure OpenAI models now support the "reasoning_effort" parameter, enabling proper configuration of reasoning capabilities for models like GPT-5.1 which default to no reasoning without this setting. [#19290](https://github.com/open-webui/open-webui/issues/19290) +- 🤖 Models with very long IDs can now be deleted correctly, resolving URL length limitations that previously prevented management operations on such models. [#18230](https://github.com/open-webui/open-webui/pull/18230) +- 🤖 Model-level streaming settings now correctly apply to API requests, ensuring "Stream Chat Response" toggle properly controls the streaming parameter. [#19154](https://github.com/open-webui/open-webui/issues/19154) +- 🖼️ Image editing configuration now correctly preserves independent OpenAI API endpoints and keys, preventing them from being overwritten by image generation settings. [#19003](https://github.com/open-webui/open-webui/issues/19003) +- 🎨 Gemini image edit settings now display correctly in the admin panel, fixing an incorrect configuration key reference that prevented proper rendering of edit options. [#19200](https://github.com/open-webui/open-webui/pull/19200) +- 🖌️ Image generation settings menu now loads correctly, resolving validation errors with AUTOMATIC1111 API authentication parameters. [#19187](https://github.com/open-webui/open-webui/issues/19187), [#19246](https://github.com/open-webui/open-webui/issues/19246) +- 📅 Date formatting in chat search and admin user chat search now correctly respects the "DEFAULT_LOCALE" environment variable, displaying dates according to the configured locale instead of always using MM/DD/YYYY format. [#19305](https://github.com/open-webui/open-webui/pull/19305), [#19020](https://github.com/open-webui/open-webui/issues/19020) +- 📝 RAG template query placeholder escaping logic was corrected to prevent unintended replacements of context values when query placeholders appear in retrieved content. [#19102](https://github.com/open-webui/open-webui/pull/19102), [#19101](https://github.com/open-webui/open-webui/issues/19101) +- 📄 RAG template prompt duplication was eliminated by removing redundant user query section from the default template. [#19099](https://github.com/open-webui/open-webui/pull/19099), [#19098](https://github.com/open-webui/open-webui/issues/19098) +- 📋 MinerU local mode configuration no longer incorrectly requires an API key, allowing proper use of local content extraction without external API credentials. [#19258](https://github.com/open-webui/open-webui/issues/19258) +- 📊 Excel file uploads now work correctly with the addition of the missing msoffcrypto-tool dependency, resolving import errors introduced by the unstructured package upgrade. [#19153](https://github.com/open-webui/open-webui/issues/19153) +- 📑 Docling parameters now properly handle JSON serialization, preventing exceptions and ensuring configuration changes are saved correctly. [#19072](https://github.com/open-webui/open-webui/pull/19072) +- 🛠️ UserValves configuration now correctly isolates settings per tool, preventing configuration contamination when multiple tools with UserValves are used simultaneously. [#19185](https://github.com/open-webui/open-webui/pull/19185), [#15569](https://github.com/open-webui/open-webui/issues/15569) +- 🔧 Tool selection prompt now correctly handles user messages without duplication, removing redundant query prefixes and improving prompt clarity. [#19122](https://github.com/open-webui/open-webui/pull/19122), [#19121](https://github.com/open-webui/open-webui/issues/19121) +- 📝 Notes chat feature now correctly submits messages to the completions endpoint, resolving errors that prevented AI model interactions. [#19079](https://github.com/open-webui/open-webui/pull/19079) +- 📝 Note PDF downloads now sanitize HTML content using DOMPurify before rendering, preventing potential DOM-based XSS attacks from malicious content in notes. [Commit](https://github.com/open-webui/open-webui/commit/03cc6ce8eb5c055115406e2304fbf7e3338b8dce) +- 📁 Archived chats now have their folder associations automatically removed to prevent unintended deletion when their previous folder is deleted. [#14578](https://github.com/open-webui/open-webui/issues/14578) +- 🔐 ElevenLabs API key is now properly obfuscated in the admin settings page, preventing plain text exposure of sensitive credentials. [#19262](https://github.com/open-webui/open-webui/pull/19262), [#19260](https://github.com/open-webui/open-webui/issues/19260) +- 🔧 MCP OAuth server metadata discovery now follows the correct specification order, ensuring proper authentication flow compliance. [#19244](https://github.com/open-webui/open-webui/pull/19244) +- 🔒 API key endpoint restrictions now properly enforce access controls for all endpoints including SCIM, preventing unintended access when "API_KEY_ALLOWED_ENDPOINTS" is configured. [#19168](https://github.com/open-webui/open-webui/issues/19168) +- 🔓 OAuth role claim parsing now supports both flat and nested claim structures, enabling compatibility with OAuth providers that deliver claims as direct properties on the user object rather than nested structures. [#19286](https://github.com/open-webui/open-webui/pull/19286) +- 🔑 OAuth MCP server verification now correctly extracts the access token value for authorization headers instead of sending the entire token dictionary. [#19149](https://github.com/open-webui/open-webui/pull/19149), [#19148](https://github.com/open-webui/open-webui/issues/19148) +- ⚙️ OAuth dynamic client registration now correctly converts empty strings to None for optional fields, preventing validation failures in MCP package integration. [#19144](https://github.com/open-webui/open-webui/pull/19144), [#19129](https://github.com/open-webui/open-webui/issues/19129) +- 🔐 OIDC authentication now correctly passes client credentials in access token requests, ensuring compatibility with providers that require these parameters per RFC 6749. [#19132](https://github.com/open-webui/open-webui/pull/19132), [#19131](https://github.com/open-webui/open-webui/issues/19131) +- 🔗 OAuth client creation now respects configured token endpoint authentication methods instead of defaulting to basic authentication, preventing failures with servers that don't support basic auth. [#19165](https://github.com/open-webui/open-webui/pull/19165) +- 📋 Text copied from chat responses in Chrome now pastes without background formatting, improving readability when pasting into word processors. [#19083](https://github.com/open-webui/open-webui/issues/19083) + +### Changed + +- 🗄️ Group membership data storage was refactored from JSON arrays to a dedicated relational database table, significantly improving query performance and scalability for instances with large numbers of users and groups, while API responses now return member counts instead of full user ID arrays. [#19239](https://github.com/open-webui/open-webui/pull/19239) +- 📄 MinerU parameter handling was refactored to pass parameters directly to the API, improving flexibility and fixing VLM backend configuration. [#19105](https://github.com/open-webui/open-webui/pull/19105), [#18446](https://github.com/open-webui/open-webui/discussions/18446) +- 🔐 API key creation is now controlled by granular user and group permissions, with the "ENABLE_API_KEY" environment variable renamed to "ENABLE_API_KEYS" and disabled by default, requiring explicit configuration at both the global and user permission levels, while related environment variables "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS" and "API_KEY_ALLOWED_ENDPOINTS" were renamed to "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS" and "API_KEYS_ALLOWED_ENDPOINTS" respectively. [#18336](https://github.com/open-webui/open-webui/pull/18336) + ## [0.6.36] - 2025-11-07 ### Added diff --git a/README.md b/README.md index 52a3821aa5..638cdacabb 100644 --- a/README.md +++ b/README.md @@ -31,32 +31,44 @@ 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. - ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction. -- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features, allowing for a more dynamic and interactive chat environment. +- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments. - 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration. - 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs. -- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support. This feature seamlessly integrates document interactions into your chat experience. You can load documents directly into the chat or add files to your document library, effortlessly accessing them using the `#` command before a query. +- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions. -- 🔍 **Web Search for RAG**: Perform web searches using providers like `SearXNG`, `Google PSE`, `Brave Search`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `TavilySearch`, `SearchApi` and `Bing` and inject the results directly into your chat experience. +- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query. + +- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience. - 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions. -- 🎨 **Image Generation Integration**: Seamlessly incorporate image generation capabilities using options such as AUTOMATIC1111 API or ComfyUI (local), and OpenAI's DALL-E (external), enriching your chat experience with dynamic visual content. +- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows. - ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel. - 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators. +- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments. + +- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance. + +- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. 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. + +- ☁️ **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage. + +- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack. + +- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers. + - 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors! - 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more. diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index c262d857ba..663f210a49 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -620,6 +620,11 @@ OAUTH_UPDATE_PICTURE_ON_LOGIN = PersistentConfig( os.environ.get("OAUTH_UPDATE_PICTURE_ON_LOGIN", "False").lower() == "true", ) +OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID = ( + os.environ.get("OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID", "False").lower() + == "true" +) + def load_oauth_providers(): OAUTH_PROVIDERS.clear() @@ -2539,6 +2544,12 @@ DOCLING_SERVER_URL = PersistentConfig( os.getenv("DOCLING_SERVER_URL", "http://docling:5001"), ) +DOCLING_API_KEY = PersistentConfig( + "DOCLING_API_KEY", + "rag.docling_api_key", + os.getenv("DOCLING_API_KEY", ""), +) + docling_params = os.getenv("DOCLING_PARAMS", "") try: docling_params = json.loads(docling_params) @@ -2551,88 +2562,6 @@ DOCLING_PARAMS = PersistentConfig( docling_params, ) -DOCLING_DO_OCR = PersistentConfig( - "DOCLING_DO_OCR", - "rag.docling_do_ocr", - os.getenv("DOCLING_DO_OCR", "True").lower() == "true", -) - -DOCLING_FORCE_OCR = PersistentConfig( - "DOCLING_FORCE_OCR", - "rag.docling_force_ocr", - os.getenv("DOCLING_FORCE_OCR", "False").lower() == "true", -) - -DOCLING_OCR_ENGINE = PersistentConfig( - "DOCLING_OCR_ENGINE", - "rag.docling_ocr_engine", - os.getenv("DOCLING_OCR_ENGINE", "tesseract"), -) - -DOCLING_OCR_LANG = PersistentConfig( - "DOCLING_OCR_LANG", - "rag.docling_ocr_lang", - os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"), -) - -DOCLING_PDF_BACKEND = PersistentConfig( - "DOCLING_PDF_BACKEND", - "rag.docling_pdf_backend", - os.getenv("DOCLING_PDF_BACKEND", "dlparse_v4"), -) - -DOCLING_TABLE_MODE = PersistentConfig( - "DOCLING_TABLE_MODE", - "rag.docling_table_mode", - os.getenv("DOCLING_TABLE_MODE", "accurate"), -) - -DOCLING_PIPELINE = PersistentConfig( - "DOCLING_PIPELINE", - "rag.docling_pipeline", - os.getenv("DOCLING_PIPELINE", "standard"), -) - -DOCLING_DO_PICTURE_DESCRIPTION = PersistentConfig( - "DOCLING_DO_PICTURE_DESCRIPTION", - "rag.docling_do_picture_description", - os.getenv("DOCLING_DO_PICTURE_DESCRIPTION", "False").lower() == "true", -) - -DOCLING_PICTURE_DESCRIPTION_MODE = PersistentConfig( - "DOCLING_PICTURE_DESCRIPTION_MODE", - "rag.docling_picture_description_mode", - os.getenv("DOCLING_PICTURE_DESCRIPTION_MODE", ""), -) - - -docling_picture_description_local = os.getenv("DOCLING_PICTURE_DESCRIPTION_LOCAL", "") -try: - docling_picture_description_local = json.loads(docling_picture_description_local) -except json.JSONDecodeError: - docling_picture_description_local = {} - - -DOCLING_PICTURE_DESCRIPTION_LOCAL = PersistentConfig( - "DOCLING_PICTURE_DESCRIPTION_LOCAL", - "rag.docling_picture_description_local", - docling_picture_description_local, -) - -docling_picture_description_api = os.getenv("DOCLING_PICTURE_DESCRIPTION_API", "") -try: - docling_picture_description_api = json.loads(docling_picture_description_api) -except json.JSONDecodeError: - docling_picture_description_api = {} - - -DOCLING_PICTURE_DESCRIPTION_API = PersistentConfig( - "DOCLING_PICTURE_DESCRIPTION_API", - "rag.docling_picture_description_api", - docling_picture_description_api, -) - - DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig( "DOCUMENT_INTELLIGENCE_ENDPOINT", "rag.document_intelligence_endpoint", @@ -2790,6 +2719,12 @@ RAG_EMBEDDING_BATCH_SIZE = PersistentConfig( ), ) +ENABLE_ASYNC_EMBEDDING = PersistentConfig( + "ENABLE_ASYNC_EMBEDDING", + "rag.enable_async_embedding", + os.environ.get("ENABLE_ASYNC_EMBEDDING", "True").lower() == "true", +) + RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None) RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 13bcc360ea..d6769f5a8e 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -230,6 +230,7 @@ from open_webui.config import ( RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_BATCH_SIZE, + ENABLE_ASYNC_EMBEDDING, RAG_TOP_K, RAG_TOP_K_RERANKER, RAG_RELEVANCE_THRESHOLD, @@ -268,18 +269,8 @@ from open_webui.config import ( EXTERNAL_DOCUMENT_LOADER_API_KEY, TIKA_SERVER_URL, DOCLING_SERVER_URL, + DOCLING_API_KEY, DOCLING_PARAMS, - DOCLING_DO_OCR, - DOCLING_FORCE_OCR, - DOCLING_OCR_ENGINE, - DOCLING_OCR_LANG, - DOCLING_PDF_BACKEND, - DOCLING_TABLE_MODE, - DOCLING_PIPELINE, - DOCLING_DO_PICTURE_DESCRIPTION, - DOCLING_PICTURE_DESCRIPTION_MODE, - DOCLING_PICTURE_DESCRIPTION_LOCAL, - DOCLING_PICTURE_DESCRIPTION_API, DOCUMENT_INTELLIGENCE_ENDPOINT, DOCUMENT_INTELLIGENCE_KEY, MISTRAL_OCR_API_BASE_URL, @@ -875,18 +866,8 @@ app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL +app.state.config.DOCLING_API_KEY = DOCLING_API_KEY app.state.config.DOCLING_PARAMS = DOCLING_PARAMS -app.state.config.DOCLING_DO_OCR = DOCLING_DO_OCR -app.state.config.DOCLING_FORCE_OCR = DOCLING_FORCE_OCR -app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE -app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG -app.state.config.DOCLING_PDF_BACKEND = DOCLING_PDF_BACKEND -app.state.config.DOCLING_TABLE_MODE = DOCLING_TABLE_MODE -app.state.config.DOCLING_PIPELINE = DOCLING_PIPELINE -app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = DOCLING_DO_PICTURE_DESCRIPTION -app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE = DOCLING_PICTURE_DESCRIPTION_MODE -app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL = DOCLING_PICTURE_DESCRIPTION_LOCAL -app.state.config.DOCLING_PICTURE_DESCRIPTION_API = DOCLING_PICTURE_DESCRIPTION_API app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY app.state.config.MISTRAL_OCR_API_BASE_URL = MISTRAL_OCR_API_BASE_URL @@ -905,6 +886,7 @@ app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE +app.state.config.ENABLE_ASYNC_EMBEDDING = ENABLE_ASYNC_EMBEDDING app.state.config.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 48bdc1ed97..39ff1cc7fb 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -19,7 +19,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Auth(Base): __tablename__ = "auth" - id = Column(String, primary_key=True) + id = Column(String, primary_key=True, unique=True) email = Column(String) password = Column(Text) active = Column(Boolean) diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index e75266be78..2a14e7a2d5 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -19,7 +19,7 @@ from sqlalchemy.sql import exists class Channel(Base): __tablename__ = "channel" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) type = Column(Text, nullable=True) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index f1607f0707..187a4522c9 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -26,7 +26,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Chat(Base): __tablename__ = "chat" - id = Column(String, primary_key=True) + id = Column(String, primary_key=True, unique=True) user_id = Column(String) title = Column(Text) chat = Column(JSON) @@ -92,6 +92,10 @@ class ChatImportForm(ChatForm): updated_at: Optional[int] = None +class ChatsImportForm(BaseModel): + chats: list[ChatImportForm] + + class ChatTitleMessagesForm(BaseModel): title: str messages: list[dict] @@ -123,6 +127,43 @@ class ChatTitleIdResponse(BaseModel): class ChatTable: + def _clean_null_bytes(self, obj): + """ + Recursively remove actual null bytes (\x00) and unicode escape \\u0000 + from strings inside dict/list structures. + Safe for JSON objects. + """ + if isinstance(obj, str): + return obj.replace("\x00", "").replace("\u0000", "") + elif isinstance(obj, dict): + return {k: self._clean_null_bytes(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [self._clean_null_bytes(v) for v in obj] + return obj + + def _sanitize_chat_row(self, chat_item): + """ + Clean a Chat SQLAlchemy model's title + chat JSON, + and return True if anything changed. + """ + changed = False + + # Clean title + if chat_item.title: + cleaned = self._clean_null_bytes(chat_item.title) + if cleaned != chat_item.title: + chat_item.title = cleaned + changed = True + + # Clean JSON + if chat_item.chat: + cleaned = self._clean_null_bytes(chat_item.chat) + if cleaned != chat_item.chat: + chat_item.chat = cleaned + changed = True + + return changed + def insert_new_chat(self, user_id: str, form_data: ChatForm) -> Optional[ChatModel]: with get_db() as db: id = str(uuid.uuid4()) @@ -130,68 +171,76 @@ class ChatTable: **{ "id": id, "user_id": user_id, - "title": ( + "title": self._clean_null_bytes( form_data.chat["title"] if "title" in form_data.chat else "New Chat" ), - "chat": form_data.chat, + "chat": self._clean_null_bytes(form_data.chat), "folder_id": form_data.folder_id, "created_at": int(time.time()), "updated_at": int(time.time()), } ) - result = Chat(**chat.model_dump()) - db.add(result) + chat_item = Chat(**chat.model_dump()) + db.add(chat_item) db.commit() - db.refresh(result) - return ChatModel.model_validate(result) if result else None + db.refresh(chat_item) + return ChatModel.model_validate(chat_item) if chat_item else None - def import_chat( + def _chat_import_form_to_chat_model( self, user_id: str, form_data: ChatImportForm - ) -> Optional[ChatModel]: - with get_db() as db: - id = str(uuid.uuid4()) - chat = ChatModel( - **{ - "id": id, - "user_id": user_id, - "title": ( - form_data.chat["title"] - if "title" in form_data.chat - else "New Chat" - ), - "chat": form_data.chat, - "meta": form_data.meta, - "pinned": form_data.pinned, - "folder_id": form_data.folder_id, - "created_at": ( - form_data.created_at - if form_data.created_at - else int(time.time()) - ), - "updated_at": ( - form_data.updated_at - if form_data.updated_at - else int(time.time()) - ), - } - ) + ) -> ChatModel: + id = str(uuid.uuid4()) + chat = ChatModel( + **{ + "id": id, + "user_id": user_id, + "title": self._clean_null_bytes( + form_data.chat["title"] if "title" in form_data.chat else "New Chat" + ), + "chat": self._clean_null_bytes(form_data.chat), + "meta": form_data.meta, + "pinned": form_data.pinned, + "folder_id": form_data.folder_id, + "created_at": ( + form_data.created_at if form_data.created_at else int(time.time()) + ), + "updated_at": ( + form_data.updated_at if form_data.updated_at else int(time.time()) + ), + } + ) + return chat - result = Chat(**chat.model_dump()) - db.add(result) + def import_chats( + self, user_id: str, chat_import_forms: list[ChatImportForm] + ) -> list[ChatModel]: + with get_db() as db: + chats = [] + + for form_data in chat_import_forms: + chat = self._chat_import_form_to_chat_model(user_id, form_data) + chats.append(Chat(**chat.model_dump())) + + db.add_all(chats) db.commit() - db.refresh(result) - return ChatModel.model_validate(result) if result else None + return [ChatModel.model_validate(chat) for chat in chats] def update_chat_by_id(self, id: str, chat: dict) -> Optional[ChatModel]: try: with get_db() as db: chat_item = db.get(Chat, id) - chat_item.chat = chat - chat_item.title = chat["title"] if "title" in chat else "New Chat" + chat_item.chat = self._clean_null_bytes(chat) + chat_item.title = ( + self._clean_null_bytes(chat["title"]) + if "title" in chat + else "New Chat" + ) + chat_item.updated_at = int(time.time()) + db.commit() db.refresh(chat_item) @@ -426,6 +475,7 @@ class ChatTable: with get_db() as db: chat = db.get(Chat, id) chat.archived = not chat.archived + chat.folder_id = None chat.updated_at = int(time.time()) db.commit() db.refresh(chat) @@ -582,8 +632,15 @@ class ChatTable: def get_chat_by_id(self, id: str) -> Optional[ChatModel]: try: with get_db() as db: - chat = db.get(Chat, id) - return ChatModel.model_validate(chat) + chat_item = db.get(Chat, id) + if chat_item is None: + return None + + if self._sanitize_chat_row(chat_item): + db.commit() + db.refresh(chat_item) + + return ChatModel.model_validate(chat_item) except Exception: return None @@ -788,24 +845,30 @@ class ChatTable: elif dialect_name == "postgresql": # PostgreSQL doesn't allow null bytes in text. We filter those out by checking # the JSON representation for \u0000 before attempting text extraction - postgres_content_sql = ( - "EXISTS (" - " SELECT 1 " - " FROM json_array_elements(Chat.chat->'messages') AS message " - " WHERE message->'content' IS NOT NULL " - " AND (message->'content')::text NOT LIKE '%\\u0000%' " - " AND LOWER(message->>'content') LIKE '%' || :content_key || '%'" - ")" - ) - postgres_content_clause = text(postgres_content_sql) - # Also filter out chats with null bytes in title + + # Safety filter: JSON field must not contain \u0000 + query = query.filter(text("Chat.chat::text NOT LIKE '%\\\\u0000%'")) + + # Safety filter: title must not contain actual null bytes query = query.filter(text("Chat.title::text NOT LIKE '%\\x00%'")) + + postgres_content_sql = """ + EXISTS ( + SELECT 1 + FROM json_array_elements(Chat.chat->'messages') AS message + WHERE json_typeof(message->'content') = 'string' + AND LOWER(message->>'content') LIKE '%' || :content_key || '%' + ) + """ + + postgres_content_clause = text(postgres_content_sql) + query = query.filter( or_( Chat.title.ilike(bindparam("title_key")), postgres_content_clause, - ).params(title_key=f"%{search_text}%", content_key=search_text) - ) + ) + ).params(title_key=f"%{search_text}%", content_key=search_text.lower()) # Check if there are any tags to filter, it should have all the tags if "none" in tag_ids: @@ -1080,6 +1143,20 @@ class ChatTable: except Exception: return False + def move_chats_by_user_id_and_folder_id( + self, user_id: str, folder_id: str, new_folder_id: Optional[str] + ) -> bool: + try: + with get_db() as db: + db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).update( + {"folder_id": new_folder_id} + ) + db.commit() + + return True + except Exception: + return False + def delete_shared_chats_by_user_id(self, user_id: str) -> bool: try: with get_db() as db: diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index 33f7f6179a..5a91804b56 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -21,7 +21,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Feedback(Base): __tablename__ = "feedback" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) version = Column(BigInteger, default=0) type = Column(Text) diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index e86000cfc8..1ed743df87 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -17,7 +17,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class File(Base): __tablename__ = "file" - id = Column(String, primary_key=True) + id = Column(String, primary_key=True, unique=True) user_id = Column(String) hash = Column(Text, nullable=True) diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 45f8247080..6e1735ecea 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -23,7 +23,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Folder(Base): __tablename__ = "folder" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) parent_id = Column(Text, nullable=True) user_id = Column(Text) name = Column(Text) diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 2020a29633..91736f949a 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -19,7 +19,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Function(Base): __tablename__ = "function" - id = Column(String, primary_key=True) + id = Column(String, primary_key=True, unique=True) user_id = Column(String) name = Column(Text) type = Column(Text) diff --git a/backend/open_webui/models/memories.py b/backend/open_webui/models/memories.py index 253371c680..f5f2492b99 100644 --- a/backend/open_webui/models/memories.py +++ b/backend/open_webui/models/memories.py @@ -14,7 +14,7 @@ from sqlalchemy import BigInteger, Column, String, Text class Memory(Base): __tablename__ = "memory" - id = Column(String, primary_key=True) + id = Column(String, primary_key=True, unique=True) user_id = Column(String) content = Column(Text) updated_at = Column(BigInteger) diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 8b0027b8e7..6aaf09ca46 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -20,7 +20,7 @@ from sqlalchemy.sql import exists class MessageReaction(Base): __tablename__ = "message_reaction" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) message_id = Column(Text) name = Column(Text) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index f5964c0579..e902a978d1 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -6,12 +6,12 @@ from open_webui.internal.db import Base, JSONField, get_db from open_webui.env import SRC_LOG_LEVELS from open_webui.models.groups import Groups -from open_webui.models.users import Users, UserResponse +from open_webui.models.users import User, UserModel, Users, UserResponse from pydantic import BaseModel, ConfigDict -from sqlalchemy import or_, and_, func +from sqlalchemy import String, cast, or_, and_, func from sqlalchemy.dialects import postgresql, sqlite from sqlalchemy import BigInteger, Column, Text, JSON, Boolean @@ -133,6 +133,11 @@ class ModelResponse(ModelModel): pass +class ModelListResponse(BaseModel): + items: list[ModelUserResponse] + total: int + + class ModelForm(BaseModel): id: str base_model_id: Optional[str] = None @@ -215,6 +220,89 @@ class ModelsTable: or has_access(user_id, permission, model.access_control, user_group_ids) ] + def search_models( + self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30 + ) -> ModelListResponse: + with get_db() as db: + # Join GroupMember so we can order by group_id when requested + query = db.query(Model, User).outerjoin(User, User.id == Model.user_id) + query = query.filter(Model.base_model_id != None) + + if filter: + query_key = filter.get("query") + if query_key: + query = query.filter( + or_( + Model.name.ilike(f"%{query_key}%"), + Model.base_model_id.ilike(f"%{query_key}%"), + ) + ) + + if filter.get("user_id"): + query = query.filter(Model.user_id == filter.get("user_id")) + + view_option = filter.get("view_option") + + if view_option == "created": + query = query.filter(Model.user_id == user_id) + elif view_option == "shared": + query = query.filter(Model.user_id != user_id) + + tag = filter.get("tag") + if tag: + # TODO: This is a simple implementation and should be improved for performance + like_pattern = f'%"{tag.lower()}"%' # `"tag"` inside JSON array + meta_text = func.lower(cast(Model.meta, String)) + + query = query.filter(meta_text.like(like_pattern)) + + order_by = filter.get("order_by") + direction = filter.get("direction") + + if order_by == "name": + if direction == "asc": + query = query.order_by(Model.name.asc()) + else: + query = query.order_by(Model.name.desc()) + elif order_by == "created_at": + if direction == "asc": + query = query.order_by(Model.created_at.asc()) + else: + query = query.order_by(Model.created_at.desc()) + elif order_by == "updated_at": + if direction == "asc": + query = query.order_by(Model.updated_at.asc()) + else: + query = query.order_by(Model.updated_at.desc()) + + else: + query = query.order_by(Model.created_at.desc()) + + # Count BEFORE pagination + total = query.count() + + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + + items = query.all() + + models = [] + for model, user in items: + models.append( + ModelUserResponse( + **ModelModel.model_validate(model).model_dump(), + user=( + UserResponse(**UserModel.model_validate(user).model_dump()) + if user + else None + ), + ) + ) + + return ModelListResponse(items=models, total=total) + def get_model_by_id(self, id: str) -> Optional[ModelModel]: try: with get_db() as db: diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 256d3bc75e..9779731a47 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -11,8 +11,8 @@ from open_webui.utils.misc import throttle from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Column, String, Text, Date -from sqlalchemy import or_ +from sqlalchemy import BigInteger, Column, String, Text, Date, exists, select +from sqlalchemy import or_, case import datetime @@ -227,9 +227,7 @@ class UsersTable: ) -> dict: with get_db() as db: # Join GroupMember so we can order by group_id when requested - query = db.query(User).outerjoin( - GroupMember, GroupMember.user_id == User.id - ) + query = db.query(User) if filter: query_key = filter.get("query") @@ -247,17 +245,28 @@ class UsersTable: if order_by and order_by.startswith("group_id:"): group_id = order_by.split(":", 1)[1] - if direction == "asc": - query = query.order_by((GroupMember.group_id == group_id).asc()) - else: - query = query.order_by( - (GroupMember.group_id == group_id).desc() + # Subquery that checks if the user belongs to the group + membership_exists = exists( + select(GroupMember.id).where( + GroupMember.user_id == User.id, + GroupMember.group_id == group_id, ) + ) + + # CASE: user in group → 1, user not in group → 0 + group_sort = case((membership_exists, 1), else_=0) + + if direction == "asc": + query = query.order_by(group_sort.asc(), User.name.asc()) + else: + query = query.order_by(group_sort.desc(), User.name.asc()) + elif order_by == "name": if direction == "asc": query = query.order_by(User.name.asc()) else: query = query.order_by(User.name.desc()) + elif order_by == "email": if direction == "asc": query = query.order_by(User.email.asc()) @@ -291,11 +300,13 @@ class UsersTable: query = query.order_by(User.created_at.desc()) # Count BEFORE pagination + query = query.distinct(User.id) total = query.count() - if skip: + # correct pagination logic + if skip is not None: query = query.offset(skip) - if limit: + if limit is not None: query = query.limit(limit) users = query.all() diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index bbc3da9bc9..fcc507e088 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -132,8 +132,9 @@ class TikaLoader: class DoclingLoader: - def __init__(self, url, file_path=None, mime_type=None, params=None): + def __init__(self, url, api_key=None, file_path=None, mime_type=None, params=None): self.url = url.rstrip("/") + self.api_key = api_key self.file_path = file_path self.mime_type = mime_type @@ -141,6 +142,10 @@ class DoclingLoader: def load(self) -> list[Document]: with open(self.file_path, "rb") as f: + headers = {} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + files = { "files": ( self.file_path, @@ -149,60 +154,15 @@ class DoclingLoader: ) } - params = {"image_export_mode": "placeholder"} - - if self.params: - if self.params.get("do_picture_description"): - params["do_picture_description"] = self.params.get( - "do_picture_description" - ) - - picture_description_mode = self.params.get( - "picture_description_mode", "" - ).lower() - - if picture_description_mode == "local" and self.params.get( - "picture_description_local", {} - ): - params["picture_description_local"] = json.dumps( - self.params.get("picture_description_local", {}) - ) - - elif picture_description_mode == "api" and self.params.get( - "picture_description_api", {} - ): - params["picture_description_api"] = json.dumps( - self.params.get("picture_description_api", {}) - ) - - params["do_ocr"] = self.params.get("do_ocr") - - params["force_ocr"] = self.params.get("force_ocr") - - if ( - self.params.get("do_ocr") - and self.params.get("ocr_engine") - and self.params.get("ocr_lang") - ): - params["ocr_engine"] = self.params.get("ocr_engine") - params["ocr_lang"] = [ - lang.strip() - for lang in self.params.get("ocr_lang").split(",") - if lang.strip() - ] - - if self.params.get("pdf_backend"): - params["pdf_backend"] = self.params.get("pdf_backend") - - if self.params.get("table_mode"): - params["table_mode"] = self.params.get("table_mode") - - if self.params.get("pipeline"): - params["pipeline"] = self.params.get("pipeline") - - endpoint = f"{self.url}/v1/convert/file" - r = requests.post(endpoint, files=files, data=params) - + r = requests.post( + f"{self.url}/v1/convert/file", + files=files, + data={ + "image_export_mode": "placeholder", + **self.params, + }, + headers=headers, + ) if r.ok: result = r.json() document_data = result.get("document", {}) @@ -211,7 +171,6 @@ class DoclingLoader: metadata = {"Content-Type": self.mime_type} if self.mime_type else {} log.debug("Docling extracted text: %s", text) - return [Document(page_content=text, metadata=metadata)] else: error_msg = f"Error calling Docling API: {r.reason}" @@ -340,6 +299,7 @@ class Loader: loader = DoclingLoader( url=self.kwargs.get("DOCLING_SERVER_URL"), + api_key=self.kwargs.get("DOCLING_API_KEY", None), file_path=file_path, mime_type=file_content_type, params=params, diff --git a/backend/open_webui/retrieval/models/external.py b/backend/open_webui/retrieval/models/external.py index a9be526b6d..822cb3e3dd 100644 --- a/backend/open_webui/retrieval/models/external.py +++ b/backend/open_webui/retrieval/models/external.py @@ -6,6 +6,7 @@ from urllib.parse import quote from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS from open_webui.retrieval.models.base_reranker import BaseReranker +from open_webui.utils.headers import include_user_info_headers log = logging.getLogger(__name__) @@ -40,22 +41,17 @@ class ExternalReranker(BaseReranker): log.info(f"ExternalReranker:predict:model {self.model}") log.info(f"ExternalReranker:predict:query {query}") + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + r = requests.post( f"{self.url}", - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, + headers=headers, json=payload, ) diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 370737ba55..b041a00471 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -1,8 +1,10 @@ import logging import os -from typing import Optional, Union +from typing import Awaitable, Optional, Union import requests +import aiohttp +import asyncio import hashlib from concurrent.futures import ThreadPoolExecutor import time @@ -27,6 +29,7 @@ from open_webui.models.notes import Notes from open_webui.retrieval.vector.main import GetResult from open_webui.utils.access_control import has_access +from open_webui.utils.headers import include_user_info_headers from open_webui.utils.misc import get_message_list from open_webui.retrieval.web.utils import get_web_loader @@ -88,14 +91,29 @@ class VectorSearchRetriever(BaseRetriever): top_k: int def _get_relevant_documents( + self, query: str, *, run_manager: CallbackManagerForRetrieverRun + ) -> list[Document]: + """Get documents relevant to a query. + + Args: + query: String to find relevant documents for. + run_manager: The callback handler to use. + + Returns: + List of relevant documents. + """ + return [] + + async def _aget_relevant_documents( self, query: str, *, run_manager: CallbackManagerForRetrieverRun, ) -> list[Document]: + embedding = await self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX) result = VECTOR_DB_CLIENT.search( collection_name=self.collection_name, - vectors=[self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)], + vectors=[embedding], limit=self.top_k, ) @@ -186,7 +204,7 @@ def get_enriched_texts(collection_result: GetResult) -> list[str]: return enriched_texts -def query_doc_with_hybrid_search( +async def query_doc_with_hybrid_search( collection_name: str, collection_result: GetResult, query: str, @@ -262,7 +280,7 @@ def query_doc_with_hybrid_search( base_compressor=compressor, base_retriever=ensemble_retriever ) - result = compression_retriever.invoke(query) + result = await compression_retriever.ainvoke(query) distances = [d.metadata.get("score") for d in result] documents = [d.page_content for d in result] @@ -381,7 +399,7 @@ def get_all_items_from_collections(collection_names: list[str]) -> dict: return merge_get_results(results) -def query_collection( +async def query_collection( collection_names: list[str], queries: list[str], embedding_function, @@ -406,7 +424,9 @@ def query_collection( return None, e # Generate all query embeddings (in one call) - query_embeddings = embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX) + query_embeddings = await embedding_function( + queries, prefix=RAG_EMBEDDING_QUERY_PREFIX + ) log.debug( f"query_collection: processing {len(queries)} queries across {len(collection_names)} collections" ) @@ -433,7 +453,7 @@ def query_collection( return merge_and_sort_query_results(results, k=k) -def query_collection_with_hybrid_search( +async def query_collection_with_hybrid_search( collection_names: list[str], queries: list[str], embedding_function, @@ -465,9 +485,9 @@ def query_collection_with_hybrid_search( f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..." ) - def process_query(collection_name, query): + async def process_query(collection_name, query): try: - result = query_doc_with_hybrid_search( + result = await query_doc_with_hybrid_search( collection_name=collection_name, collection_result=collection_results[collection_name], query=query, @@ -487,15 +507,16 @@ def query_collection_with_hybrid_search( # Prepare tasks for all collections and queries # Avoid running any tasks for collections that failed to fetch data (have assigned None) tasks = [ - (cn, q) - for cn in collection_names - if collection_results[cn] is not None - for q in queries + (collection_name, query) + for collection_name in collection_names + if collection_results[collection_name] is not None + for query in queries ] - with ThreadPoolExecutor() as executor: - future_results = [executor.submit(process_query, cn, q) for cn, q in tasks] - task_results = [future.result() for future in future_results] + # Run all queries in parallel using asyncio.gather + task_results = await asyncio.gather( + *[process_query(collection_name, query) for collection_name, query in tasks] + ) for result, err in task_results: if err is not None: @@ -511,6 +532,248 @@ def query_collection_with_hybrid_search( return merge_and_sort_query_results(results, k=k) +def generate_openai_batch_embeddings( + model: str, + texts: list[str], + url: str = "https://api.openai.com/v1", + key: str = "", + prefix: str = None, + user: UserModel = None, +) -> Optional[list[list[float]]]: + try: + log.debug( + f"generate_openai_batch_embeddings:model {model} batch size: {len(texts)}" + ) + json_data = {"input": texts, "model": model} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {key}", + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + + r = requests.post( + f"{url}/embeddings", + headers=headers, + json=json_data, + ) + r.raise_for_status() + data = r.json() + if "data" in data: + return [elem["embedding"] for elem in data["data"]] + else: + raise "Something went wrong :/" + except Exception as e: + log.exception(f"Error generating openai batch embeddings: {e}") + return None + + +async def agenerate_openai_batch_embeddings( + model: str, + texts: list[str], + url: str = "https://api.openai.com/v1", + key: str = "", + prefix: str = None, + user: UserModel = None, +) -> Optional[list[list[float]]]: + try: + log.debug( + f"agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}" + ) + form_data = {"input": texts, "model": model} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {key}", + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.post( + f"{url}/embeddings", headers=headers, json=form_data + ) as r: + r.raise_for_status() + data = await r.json() + if "data" in data: + return [item["embedding"] for item in data["data"]] + else: + raise Exception("Something went wrong :/") + except Exception as e: + log.exception(f"Error generating openai batch embeddings: {e}") + return None + + +def generate_azure_openai_batch_embeddings( + model: str, + texts: list[str], + url: str, + key: str = "", + version: str = "", + prefix: str = None, + user: UserModel = None, +) -> Optional[list[list[float]]]: + try: + log.debug( + f"generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}" + ) + json_data = {"input": texts} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + + url = f"{url}/openai/deployments/{model}/embeddings?api-version={version}" + + for _ in range(5): + headers = { + "Content-Type": "application/json", + "api-key": key, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + + r = requests.post( + url, + headers=headers, + json=json_data, + ) + if r.status_code == 429: + retry = float(r.headers.get("Retry-After", "1")) + time.sleep(retry) + continue + r.raise_for_status() + data = r.json() + if "data" in data: + return [elem["embedding"] for elem in data["data"]] + else: + raise Exception("Something went wrong :/") + return None + except Exception as e: + log.exception(f"Error generating azure openai batch embeddings: {e}") + return None + + +async def agenerate_azure_openai_batch_embeddings( + model: str, + texts: list[str], + url: str, + key: str = "", + version: str = "", + prefix: str = None, + user: UserModel = None, +) -> Optional[list[list[float]]]: + try: + log.debug( + f"agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}" + ) + form_data = {"input": texts} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + + full_url = f"{url}/openai/deployments/{model}/embeddings?api-version={version}" + + headers = { + "Content-Type": "application/json", + "api-key": key, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.post(full_url, headers=headers, json=form_data) as r: + r.raise_for_status() + data = await r.json() + if "data" in data: + return [item["embedding"] for item in data["data"]] + else: + raise Exception("Something went wrong :/") + except Exception as e: + log.exception(f"Error generating azure openai batch embeddings: {e}") + return None + + +def generate_ollama_batch_embeddings( + model: str, + texts: list[str], + url: str, + key: str = "", + prefix: str = None, + user: UserModel = None, +) -> Optional[list[list[float]]]: + try: + log.debug( + f"generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}" + ) + json_data = {"input": texts, "model": model} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {key}", + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + + r = requests.post( + f"{url}/api/embed", + headers=headers, + json=json_data, + ) + r.raise_for_status() + data = r.json() + + if "embeddings" in data: + return data["embeddings"] + else: + raise "Something went wrong :/" + except Exception as e: + log.exception(f"Error generating ollama batch embeddings: {e}") + return None + + +async def agenerate_ollama_batch_embeddings( + model: str, + texts: list[str], + url: str, + key: str = "", + prefix: str = None, + user: UserModel = None, +) -> Optional[list[list[float]]]: + try: + log.debug( + f"agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}" + ) + form_data = {"input": texts, "model": model} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {key}", + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.post( + f"{url}/api/embed", headers=headers, json=form_data + ) as r: + r.raise_for_status() + data = await r.json() + if "embeddings" in data: + return data["embeddings"] + else: + raise Exception("Something went wrong :/") + except Exception as e: + log.exception(f"Error generating ollama batch embeddings: {e}") + return None + + def get_embedding_function( embedding_engine, embedding_model, @@ -519,13 +782,24 @@ def get_embedding_function( key, embedding_batch_size, azure_api_version=None, -): + enable_async=True, +) -> Awaitable: if embedding_engine == "": - return lambda query, prefix=None, user=None: embedding_function.encode( - query, **({"prompt": prefix} if prefix else {}) - ).tolist() + # Sentence transformers: CPU-bound sync operation + async def async_embedding_function(query, prefix=None, user=None): + return await asyncio.to_thread( + ( + lambda query, prefix=None: embedding_function.encode( + query, **({"prompt": prefix} if prefix else {}) + ).tolist() + ), + query, + prefix, + ) + + return async_embedding_function elif embedding_engine in ["ollama", "openai", "azure_openai"]: - func = lambda query, prefix=None, user=None: generate_embeddings( + embedding_function = lambda query, prefix=None, user=None: generate_embeddings( engine=embedding_engine, model=embedding_model, text=query, @@ -536,29 +810,100 @@ def get_embedding_function( azure_api_version=azure_api_version, ) - def generate_multiple(query, prefix, user, func): + async def async_embedding_function(query, prefix=None, user=None): if isinstance(query, list): - embeddings = [] - for i in range(0, len(query), embedding_batch_size): - batch_embeddings = func( - query[i : i + embedding_batch_size], - prefix=prefix, - user=user, - ) + # Create batches + batches = [ + query[i : i + embedding_batch_size] + for i in range(0, len(query), embedding_batch_size) + ] + if enable_async: + log.debug( + f"generate_multiple_async: Processing {len(batches)} batches in parallel" + ) + # Execute all batches in parallel + tasks = [ + embedding_function(batch, prefix=prefix, user=user) + for batch in batches + ] + batch_results = await asyncio.gather(*tasks) + else: + log.debug( + f"generate_multiple_async: Processing {len(batches)} batches sequentially" + ) + batch_results = [] + for batch in batches: + batch_results.append( + await embedding_function(batch, prefix=prefix, user=user) + ) + + # Flatten results + embeddings = [] + for batch_embeddings in batch_results: if isinstance(batch_embeddings, list): embeddings.extend(batch_embeddings) + + log.debug( + f"generate_multiple_async: Generated {len(embeddings)} embeddings from {len(batches)} parallel batches" + ) return embeddings else: - return func(query, prefix, user) + return await embedding_function(query, prefix, user) - return lambda query, prefix=None, user=None: generate_multiple( - query, prefix, user, func - ) + return async_embedding_function else: raise ValueError(f"Unknown embedding engine: {embedding_engine}") +async def generate_embeddings( + engine: str, + model: str, + text: Union[str, list[str]], + prefix: Union[str, None] = None, + **kwargs, +): + url = kwargs.get("url", "") + key = kwargs.get("key", "") + user = kwargs.get("user") + + if prefix is not None and RAG_EMBEDDING_PREFIX_FIELD_NAME is None: + if isinstance(text, list): + text = [f"{prefix}{text_element}" for text_element in text] + else: + text = f"{prefix}{text}" + + if engine == "ollama": + embeddings = await agenerate_ollama_batch_embeddings( + **{ + "model": model, + "texts": text if isinstance(text, list) else [text], + "url": url, + "key": key, + "prefix": prefix, + "user": user, + } + ) + return embeddings[0] if isinstance(text, str) else embeddings + elif engine == "openai": + embeddings = await agenerate_openai_batch_embeddings( + model, text if isinstance(text, list) else [text], url, key, prefix, user + ) + return embeddings[0] if isinstance(text, str) else embeddings + elif engine == "azure_openai": + azure_api_version = kwargs.get("azure_api_version", "") + embeddings = await agenerate_azure_openai_batch_embeddings( + model, + text if isinstance(text, list) else [text], + url, + key, + azure_api_version, + prefix, + user, + ) + return embeddings[0] if isinstance(text, str) else embeddings + + def get_reranking_function(reranking_engine, reranking_model, reranking_function): if reranking_function is None: return None @@ -572,7 +917,7 @@ def get_reranking_function(reranking_engine, reranking_model, reranking_function ) -def get_sources_from_items( +async def get_sources_from_items( request, items, queries, @@ -800,7 +1145,7 @@ def get_sources_from_items( query_result = None # Initialize to None if hybrid_search: try: - query_result = query_collection_with_hybrid_search( + query_result = await query_collection_with_hybrid_search( collection_names=collection_names, queries=queries, embedding_function=embedding_function, @@ -818,7 +1163,7 @@ def get_sources_from_items( # fallback to non-hybrid search if not hybrid_search and query_result is None: - query_result = query_collection( + query_result = await query_collection( collection_names=collection_names, queries=queries, embedding_function=embedding_function, @@ -894,199 +1239,6 @@ def get_model_path(model: str, update_model: bool = False): return model -def generate_openai_batch_embeddings( - model: str, - texts: list[str], - url: str = "https://api.openai.com/v1", - key: str = "", - prefix: str = None, - user: UserModel = None, -) -> Optional[list[list[float]]]: - try: - log.debug( - f"generate_openai_batch_embeddings:model {model} batch size: {len(texts)}" - ) - json_data = {"input": texts, "model": model} - if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): - json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - - r = requests.post( - f"{url}/embeddings", - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {key}", - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, - json=json_data, - ) - r.raise_for_status() - data = r.json() - if "data" in data: - return [elem["embedding"] for elem in data["data"]] - else: - raise "Something went wrong :/" - except Exception as e: - log.exception(f"Error generating openai batch embeddings: {e}") - return None - - -def generate_azure_openai_batch_embeddings( - model: str, - texts: list[str], - url: str, - key: str = "", - version: str = "", - prefix: str = None, - user: UserModel = None, -) -> Optional[list[list[float]]]: - try: - log.debug( - f"generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}" - ) - json_data = {"input": texts} - if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): - json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - - url = f"{url}/openai/deployments/{model}/embeddings?api-version={version}" - - for _ in range(5): - r = requests.post( - url, - headers={ - "Content-Type": "application/json", - "api-key": key, - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, - json=json_data, - ) - if r.status_code == 429: - retry = float(r.headers.get("Retry-After", "1")) - time.sleep(retry) - continue - r.raise_for_status() - data = r.json() - if "data" in data: - return [elem["embedding"] for elem in data["data"]] - else: - raise Exception("Something went wrong :/") - return None - except Exception as e: - log.exception(f"Error generating azure openai batch embeddings: {e}") - return None - - -def generate_ollama_batch_embeddings( - model: str, - texts: list[str], - url: str, - key: str = "", - prefix: str = None, - user: UserModel = None, -) -> Optional[list[list[float]]]: - try: - log.debug( - f"generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}" - ) - json_data = {"input": texts, "model": model} - if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): - json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - - r = requests.post( - f"{url}/api/embed", - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {key}", - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS - else {} - ), - }, - json=json_data, - ) - r.raise_for_status() - data = r.json() - - if "embeddings" in data: - return data["embeddings"] - else: - raise "Something went wrong :/" - except Exception as e: - log.exception(f"Error generating ollama batch embeddings: {e}") - return None - - -def generate_embeddings( - engine: str, - model: str, - text: Union[str, list[str]], - prefix: Union[str, None] = None, - **kwargs, -): - url = kwargs.get("url", "") - key = kwargs.get("key", "") - user = kwargs.get("user") - - if prefix is not None and RAG_EMBEDDING_PREFIX_FIELD_NAME is None: - if isinstance(text, list): - text = [f"{prefix}{text_element}" for text_element in text] - else: - text = f"{prefix}{text}" - - if engine == "ollama": - embeddings = generate_ollama_batch_embeddings( - **{ - "model": model, - "texts": text if isinstance(text, list) else [text], - "url": url, - "key": key, - "prefix": prefix, - "user": user, - } - ) - return embeddings[0] if isinstance(text, str) else embeddings - elif engine == "openai": - embeddings = generate_openai_batch_embeddings( - model, text if isinstance(text, list) else [text], url, key, prefix, user - ) - return embeddings[0] if isinstance(text, str) else embeddings - elif engine == "azure_openai": - azure_api_version = kwargs.get("azure_api_version", "") - embeddings = generate_azure_openai_batch_embeddings( - model, - text if isinstance(text, list) else [text], - url, - key, - azure_api_version, - prefix, - user, - ) - return embeddings[0] if isinstance(text, str) else embeddings - - import operator from typing import Optional, Sequence @@ -1109,6 +1261,25 @@ class RerankCompressor(BaseDocumentCompressor): documents: Sequence[Document], query: str, callbacks: Optional[Callbacks] = None, + ) -> Sequence[Document]: + """Compress retrieved documents given the query context. + + Args: + documents: The retrieved documents. + query: The query context. + callbacks: Optional callbacks to run during compression. + + Returns: + The compressed documents. + + """ + return [] + + async def acompress_documents( + self, + documents: Sequence[Document], + query: str, + callbacks: Optional[Callbacks] = None, ) -> Sequence[Document]: reranking = self.reranking_function is not None @@ -1118,8 +1289,10 @@ class RerankCompressor(BaseDocumentCompressor): else: from sentence_transformers import util - query_embedding = self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX) - document_embedding = self.embedding_function( + query_embedding = await self.embedding_function( + query, RAG_EMBEDDING_QUERY_PREFIX + ) + document_embedding = await self.embedding_function( [doc.page_content for doc in documents], RAG_EMBEDDING_CONTENT_PREFIX ) scores = util.cos_sim(query_embedding, document_embedding)[0] diff --git a/backend/open_webui/retrieval/vector/dbs/weaviate.py b/backend/open_webui/retrieval/vector/dbs/weaviate.py index b90d24b499..6bb8a1ecb4 100644 --- a/backend/open_webui/retrieval/vector/dbs/weaviate.py +++ b/backend/open_webui/retrieval/vector/dbs/weaviate.py @@ -10,22 +10,27 @@ from open_webui.retrieval.vector.main import ( GetResult, ) from open_webui.retrieval.vector.utils import process_metadata -from open_webui.config import WEAVIATE_HTTP_HOST, WEAVIATE_HTTP_PORT, WEAVIATE_GRPC_PORT, WEAVIATE_API_KEY +from open_webui.config import ( + WEAVIATE_HTTP_HOST, + WEAVIATE_HTTP_PORT, + WEAVIATE_GRPC_PORT, + WEAVIATE_API_KEY, +) def _convert_uuids_to_strings(obj: Any) -> Any: """ Recursively convert UUID objects to strings in nested data structures. - + This function handles: - UUID objects -> string - Dictionaries with UUID values - Lists/Tuples with UUID values - Nested combinations of the above - + Args: obj: Any object that might contain UUIDs - + Returns: The same object structure with UUIDs converted to strings """ @@ -41,23 +46,23 @@ def _convert_uuids_to_strings(obj: Any) -> Any: return obj - - class WeaviateClient(VectorDBBase): def __init__(self): self.url = WEAVIATE_HTTP_HOST try: - # Build connection parameters + # Build connection parameters connection_params = { "host": WEAVIATE_HTTP_HOST, "port": WEAVIATE_HTTP_PORT, "grpc_port": WEAVIATE_GRPC_PORT, } - + # Only add auth_credentials if WEAVIATE_API_KEY exists and is not empty if WEAVIATE_API_KEY: - connection_params["auth_credentials"] = weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY) - + connection_params["auth_credentials"] = ( + weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY) + ) + self.client = weaviate.connect_to_local(**connection_params) self.client.connect() except Exception as e: @@ -73,16 +78,18 @@ class WeaviateClient(VectorDBBase): # The name can only contain letters, numbers, and the underscore (_) character. Spaces are not allowed. # Replace hyphens with underscores and keep only alphanumeric characters - name = re.sub(r'[^a-zA-Z0-9_]', '', collection_name.replace("-", "_")) + name = re.sub(r"[^a-zA-Z0-9_]", "", collection_name.replace("-", "_")) name = name.strip("_") if not name: - raise ValueError("Could not sanitize collection name to be a valid Weaviate class name") + raise ValueError( + "Could not sanitize collection name to be a valid Weaviate class name" + ) # Ensure it starts with a letter and is capitalized if not name[0].isalpha(): name = "C" + name - + return name[0].upper() + name[1:] def has_collection(self, collection_name: str) -> bool: @@ -99,8 +106,10 @@ class WeaviateClient(VectorDBBase): name=collection_name, vector_config=weaviate.classes.config.Configure.Vectors.self_provided(), properties=[ - weaviate.classes.config.Property(name="text", data_type=weaviate.classes.config.DataType.TEXT), - ] + weaviate.classes.config.Property( + name="text", data_type=weaviate.classes.config.DataType.TEXT + ), + ], ) def insert(self, collection_name: str, items: List[VectorItem]) -> None: @@ -109,21 +118,21 @@ class WeaviateClient(VectorDBBase): self._create_collection(sane_collection_name) collection = self.client.collections.get(sane_collection_name) - + with collection.batch.fixed_size(batch_size=100) as batch: for item in items: item_uuid = str(uuid.uuid4()) if not item["id"] else str(item["id"]) - + properties = {"text": item["text"]} if item["metadata"]: - clean_metadata = _convert_uuids_to_strings(process_metadata(item["metadata"])) + clean_metadata = _convert_uuids_to_strings( + process_metadata(item["metadata"]) + ) clean_metadata.pop("text", None) properties.update(clean_metadata) - + batch.add_object( - properties=properties, - uuid=item_uuid, - vector=item["vector"] + properties=properties, uuid=item_uuid, vector=item["vector"] ) def upsert(self, collection_name: str, items: List[VectorItem]) -> None: @@ -132,21 +141,21 @@ class WeaviateClient(VectorDBBase): self._create_collection(sane_collection_name) collection = self.client.collections.get(sane_collection_name) - + with collection.batch.fixed_size(batch_size=100) as batch: for item in items: item_uuid = str(item["id"]) if item["id"] else None - + properties = {"text": item["text"]} if item["metadata"]: - clean_metadata = _convert_uuids_to_strings(process_metadata(item["metadata"])) + clean_metadata = _convert_uuids_to_strings( + process_metadata(item["metadata"]) + ) clean_metadata.pop("text", None) properties.update(clean_metadata) - + batch.add_object( - properties=properties, - uuid=item_uuid, - vector=item["vector"] + properties=properties, uuid=item_uuid, vector=item["vector"] ) def search( @@ -157,9 +166,14 @@ class WeaviateClient(VectorDBBase): return None collection = self.client.collections.get(sane_collection_name) - - result_ids, result_documents, result_metadatas, result_distances = [], [], [], [] - + + result_ids, result_documents, result_metadatas, result_distances = ( + [], + [], + [], + [], + ) + for vector_embedding in vectors: try: response = collection.query.near_vector( @@ -167,21 +181,28 @@ class WeaviateClient(VectorDBBase): limit=limit, return_metadata=weaviate.classes.query.MetadataQuery(distance=True), ) - + ids = [str(obj.uuid) for obj in response.objects] documents = [] metadatas = [] distances = [] - + for obj in response.objects: properties = dict(obj.properties) if obj.properties else {} documents.append(properties.pop("text", "")) metadatas.append(_convert_uuids_to_strings(properties)) - + # Weaviate has cosine distance, 2 (worst) -> 0 (best). Re-ordering to 0 -> 1 - raw_distances = [obj.metadata.distance if obj.metadata and obj.metadata.distance else 2.0 for obj in response.objects] + raw_distances = [ + ( + obj.metadata.distance + if obj.metadata and obj.metadata.distance + else 2.0 + ) + for obj in response.objects + ] distances = [(2 - dist) / 2 for dist in raw_distances] - + result_ids.append(ids) result_documents.append(documents) result_metadatas.append(metadatas) @@ -191,7 +212,7 @@ class WeaviateClient(VectorDBBase): result_documents.append([]) result_metadatas.append([]) result_distances.append([]) - + return SearchResult( **{ "ids": result_ids, @@ -209,16 +230,26 @@ class WeaviateClient(VectorDBBase): return None collection = self.client.collections.get(sane_collection_name) - + weaviate_filter = None if filter: for key, value in filter.items(): - prop_filter = weaviate.classes.query.Filter.by_property(name=key).equal(value) - weaviate_filter = prop_filter if weaviate_filter is None else weaviate.classes.query.Filter.all_of([weaviate_filter, prop_filter]) - + prop_filter = weaviate.classes.query.Filter.by_property(name=key).equal( + value + ) + weaviate_filter = ( + prop_filter + if weaviate_filter is None + else weaviate.classes.query.Filter.all_of( + [weaviate_filter, prop_filter] + ) + ) + try: - response = collection.query.fetch_objects(filters=weaviate_filter, limit=limit) - + response = collection.query.fetch_objects( + filters=weaviate_filter, limit=limit + ) + ids = [str(obj.uuid) for obj in response.objects] documents = [] metadatas = [] @@ -252,10 +283,10 @@ class WeaviateClient(VectorDBBase): properties = dict(item.properties) if item.properties else {} documents.append(properties.pop("text", "")) metadatas.append(_convert_uuids_to_strings(properties)) - + if not ids: return None - + return GetResult( **{ "ids": [ids], @@ -285,9 +316,17 @@ class WeaviateClient(VectorDBBase): elif filter: weaviate_filter = None for key, value in filter.items(): - prop_filter = weaviate.classes.query.Filter.by_property(name=key).equal(value) - weaviate_filter = prop_filter if weaviate_filter is None else weaviate.classes.query.Filter.all_of([weaviate_filter, prop_filter]) - + prop_filter = weaviate.classes.query.Filter.by_property( + name=key + ).equal(value) + weaviate_filter = ( + prop_filter + if weaviate_filter is None + else weaviate.classes.query.Filter.all_of( + [weaviate_filter, prop_filter] + ) + ) + if weaviate_filter: collection.data.delete_many(where=weaviate_filter) except Exception: diff --git a/backend/open_webui/retrieval/web/main.py b/backend/open_webui/retrieval/web/main.py index d8cfb11ba0..6d2fd1bc5a 100644 --- a/backend/open_webui/retrieval/web/main.py +++ b/backend/open_webui/retrieval/web/main.py @@ -5,7 +5,8 @@ from urllib.parse import urlparse from pydantic import BaseModel -from open_webui.retrieval.web.utils import is_string_allowed, resolve_hostname +from open_webui.retrieval.web.utils import resolve_hostname +from open_webui.utils.misc import is_string_allowed def get_filtered_results(results, filter_list): diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 127c703442..bdbde0b3a9 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -42,7 +42,7 @@ from open_webui.config import ( WEB_FETCH_FILTER_LIST, ) from open_webui.env import SRC_LOG_LEVELS - +from open_webui.utils.misc import is_string_allowed log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) @@ -59,39 +59,6 @@ def resolve_hostname(hostname): return ipv4_addresses, ipv6_addresses -def get_allow_block_lists(filter_list): - allow_list = [] - block_list = [] - - if filter_list: - for d in filter_list: - if d.startswith("!"): - # Domains starting with "!" → blocked - block_list.append(d[1:]) - else: - # Domains starting without "!" → allowed - allow_list.append(d) - - return allow_list, block_list - - -def is_string_allowed(string: str, filter_list: Optional[list[str]] = None) -> bool: - if not filter_list: - return True - - allow_list, block_list = get_allow_block_lists(filter_list) - # If allow list is non-empty, require domain to match one of them - if allow_list: - if not any(string.endswith(allowed) for allowed in allow_list): - return False - - # Block list always removes matches - if any(string.endswith(blocked) for blocked in block_list): - return False - - return True - - def validate_url(url: Union[str, Sequence[str]]): if isinstance(url, str): if isinstance(validators.url(url), validators.ValidationError): diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 1edf31fa9c..9c84f9c704 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -16,7 +16,6 @@ import aiohttp import aiofiles import requests import mimetypes -from urllib.parse import urljoin, quote from fastapi import ( Depends, @@ -1026,7 +1025,9 @@ def transcription_handler(request, file_path, metadata, user=None): ) -def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None): +def transcribe( + request: Request, file_path: str, metadata: Optional[dict] = None, user=None +): log.info(f"transcribe: {file_path} {metadata}") if is_audio_conversion_required(file_path): @@ -1053,7 +1054,9 @@ def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None with ThreadPoolExecutor() as executor: # Submit tasks for each chunk_path futures = [ - executor.submit(transcription_handler, request, chunk_path, metadata, user) + executor.submit( + transcription_handler, request, chunk_path, metadata, user + ) for chunk_path in chunk_paths ] # Gather results as they complete diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index c30c1d48d4..764196c5f1 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -4,6 +4,7 @@ import time import datetime import logging from aiohttp import ClientSession +import urllib from open_webui.models.auths import ( AddUserForm, @@ -499,6 +500,10 @@ async def signin(request: Request, response: Response, form_data: SigninForm): if WEBUI_AUTH_TRUSTED_NAME_HEADER: name = request.headers.get(WEBUI_AUTH_TRUSTED_NAME_HEADER, email) + try: + name = urllib.parse.unquote(name, encoding="utf-8") + except Exception as e: + pass if not Users.get_user_by_email(email.lower()): await signup( @@ -694,11 +699,11 @@ async def signup(request: Request, response: Response, form_data: SignupForm): if not has_users: # Disable signup after the first user is created request.app.state.config.ENABLE_SIGNUP = False - - default_group_id = getattr(request.app.state.config, 'DEFAULT_GROUP_ID', "") + + default_group_id = getattr(request.app.state.config, "DEFAULT_GROUP_ID", "") if default_group_id and default_group_id: Groups.add_users_to_group(default_group_id, [user.id]) - + return { "token": token, "token_type": "Bearer", @@ -928,7 +933,7 @@ class AdminConfig(BaseModel): @router.post("/admin/config") async def update_admin_config( request: Request, form_data: AdminConfig, user=Depends(get_admin_user) -): +): request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS request.app.state.config.WEBUI_URL = form_data.WEBUI_URL request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 2587c5ff8e..78cd8bdb1a 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -7,6 +7,7 @@ from open_webui.socket.main import get_event_emitter from open_webui.models.chats import ( ChatForm, ChatImportForm, + ChatsImportForm, ChatResponse, Chats, ChatTitleIdResponse, @@ -142,26 +143,15 @@ async def create_new_chat(form_data: ChatForm, user=Depends(get_verified_user)): ############################ -# ImportChat +# ImportChats ############################ -@router.post("/import", response_model=Optional[ChatResponse]) -async def import_chat(form_data: ChatImportForm, user=Depends(get_verified_user)): +@router.post("/import", response_model=list[ChatResponse]) +async def import_chats(form_data: ChatsImportForm, user=Depends(get_verified_user)): try: - chat = Chats.import_chat(user.id, form_data) - if chat: - tags = chat.meta.get("tags", []) - for tag_id in tags: - tag_id = tag_id.replace(" ", "_").lower() - tag_name = " ".join([word.capitalize() for word in tag_id.split("_")]) - if ( - tag_id != "none" - and Tags.get_tag_by_name_and_user_id(tag_name, user.id) is None - ): - Tags.insert_new_tag(tag_name, user.id) - - return ChatResponse(**chat.model_dump()) + chats = Chats.import_chats(user.id, form_data.chats) + return chats except Exception as e: log.exception(e) raise HTTPException( @@ -228,7 +218,7 @@ async def get_chat_list_by_folder_id( folder_id: str, page: Optional[int] = 1, user=Depends(get_verified_user) ): try: - limit = 60 + limit = 10 skip = (page - 1) * limit return [ @@ -658,19 +648,28 @@ async def clone_chat_by_id( "title": form_data.title if form_data.title else f"Clone of {chat.title}", } - chat = Chats.import_chat( + chats = Chats.import_chats( user.id, - ChatImportForm( - **{ - "chat": updated_chat, - "meta": chat.meta, - "pinned": chat.pinned, - "folder_id": chat.folder_id, - } - ), + [ + ChatImportForm( + **{ + "chat": updated_chat, + "meta": chat.meta, + "pinned": chat.pinned, + "folder_id": chat.folder_id, + } + ) + ], ) - return ChatResponse(**chat.model_dump()) + if chats: + chat = chats[0] + return ChatResponse(**chat.model_dump()) + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(), + ) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() @@ -698,18 +697,28 @@ async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)): "title": f"Clone of {chat.title}", } - chat = Chats.import_chat( + chats = Chats.import_chats( user.id, - ChatImportForm( - **{ - "chat": updated_chat, - "meta": chat.meta, - "pinned": chat.pinned, - "folder_id": chat.folder_id, - } - ), + [ + ChatImportForm( + **{ + "chat": updated_chat, + "meta": chat.meta, + "pinned": chat.pinned, + "folder_id": chat.folder_id, + } + ) + ], ) - return ChatResponse(**chat.model_dump()) + + if chats: + chat = chats[0] + return ChatResponse(**chat.model_dump()) + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(), + ) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index b242b08e3a..03212bdb7c 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -258,7 +258,10 @@ async def update_folder_is_expanded_by_id( @router.delete("/{id}") async def delete_folder_by_id( - request: Request, id: str, user=Depends(get_verified_user) + request: Request, + id: str, + delete_contents: Optional[bool] = True, + user=Depends(get_verified_user), ): if Chats.count_chats_by_folder_id_and_user_id(id, user.id): chat_delete_permission = has_permission( @@ -277,8 +280,14 @@ async def delete_folder_by_id( if folder: try: 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) + if delete_contents: + Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id) + else: + Chats.move_chats_by_user_id_and_folder_id( + user.id, folder_id, None + ) return True except Exception as e: diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index c4e67ae9ea..8aabf0f73b 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -549,9 +549,7 @@ async def image_generations( if ENABLE_FORWARD_USER_INFO_HEADERS: headers = include_user_info_headers(headers, user) - url = ( - f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations", - ) + url = f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations" if request.app.state.config.IMAGES_OPENAI_API_VERSION: url = f"{url}?api-version={request.app.state.config.IMAGES_OPENAI_API_VERSION}" @@ -838,13 +836,13 @@ async def image_edits( except Exception as e: raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) - def get_image_file_item(base64_string): + def get_image_file_item(base64_string, param_name="image"): data = base64_string header, encoded = data.split(",", 1) mime_type = header.split(";")[0].lstrip("data:") image_data = base64.b64decode(encoded) return ( - "image", + param_name, ( f"{uuid.uuid4()}.png", io.BytesIO(image_data), @@ -879,7 +877,7 @@ async def image_edits( files = [get_image_file_item(form_data.image)] elif isinstance(form_data.image, list): for img in form_data.image: - files.append(get_image_file_item(img)) + files.append(get_image_file_item(img, "image[]")) url_search_params = "" if request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION: diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 71722d706e..ad47fc1686 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -1,6 +1,7 @@ from typing import List, Optional from pydantic import BaseModel from fastapi import APIRouter, Depends, HTTPException, status, Request, Query +from fastapi.concurrency import run_in_threadpool import logging from open_webui.models.knowledge import ( @@ -223,7 +224,8 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us failed_files = [] for file in files: try: - process_file( + await run_in_threadpool( + process_file, request, ProcessFileForm( file_id=file.id, collection_name=knowledge_base.id diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 11b3d0c96c..8e45a14dfb 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel import logging +import asyncio from typing import Optional from open_webui.models.memories import Memories, MemoryModel @@ -17,7 +18,7 @@ router = APIRouter() @router.get("/ef") async def get_embeddings(request: Request): - return {"result": request.app.state.EMBEDDING_FUNCTION("hello world")} + return {"result": await request.app.state.EMBEDDING_FUNCTION("hello world")} ############################ @@ -51,15 +52,15 @@ async def add_memory( ): memory = Memories.insert_new_memory(user.id, form_data.content) + vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) + VECTOR_DB_CLIENT.upsert( collection_name=f"user-memory-{user.id}", items=[ { "id": memory.id, "text": memory.content, - "vector": request.app.state.EMBEDDING_FUNCTION( - memory.content, user=user - ), + "vector": vector, "metadata": {"created_at": memory.created_at}, } ], @@ -86,9 +87,11 @@ async def query_memory( if not memories: raise HTTPException(status_code=404, detail="No memories found for user") + vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user) + results = VECTOR_DB_CLIENT.search( collection_name=f"user-memory-{user.id}", - vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)], + vectors=[vector], limit=form_data.k, ) @@ -105,21 +108,28 @@ async def reset_memory_from_vector_db( VECTOR_DB_CLIENT.delete_collection(f"user-memory-{user.id}") memories = Memories.get_memories_by_user_id(user.id) + + # Generate vectors in parallel + vectors = await asyncio.gather( + *[ + request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) + for memory in memories + ] + ) + VECTOR_DB_CLIENT.upsert( collection_name=f"user-memory-{user.id}", items=[ { "id": memory.id, "text": memory.content, - "vector": request.app.state.EMBEDDING_FUNCTION( - memory.content, user=user - ), + "vector": vectors[idx], "metadata": { "created_at": memory.created_at, "updated_at": memory.updated_at, }, } - for memory in memories + for idx, memory in enumerate(memories) ], ) @@ -164,15 +174,15 @@ async def update_memory_by_id( raise HTTPException(status_code=404, detail="Memory not found") if form_data.content is not None: + vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) + VECTOR_DB_CLIENT.upsert( collection_name=f"user-memory-{user.id}", items=[ { "id": memory.id, "text": memory.content, - "vector": request.app.state.EMBEDDING_FUNCTION( - memory.content, user=user - ), + "vector": vector, "metadata": { "created_at": memory.created_at, "updated_at": memory.updated_at, diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index a689d26e98..93d8cb8bf7 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -9,7 +9,7 @@ from open_webui.models.models import ( ModelForm, ModelModel, ModelResponse, - ModelUserResponse, + ModelListResponse, Models, ) @@ -44,14 +44,43 @@ def is_valid_model_id(model_id: str) -> bool: ########################### +PAGE_ITEM_COUNT = 30 + + @router.get( - "/list", response_model=list[ModelUserResponse] + "/list", response_model=ModelListResponse ) # do NOT use "/" as path, conflicts with main.py -async def get_models(id: Optional[str] = None, user=Depends(get_verified_user)): - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - return Models.get_models() - else: - return Models.get_models_by_user_id(user.id) +async def get_models( + query: Optional[str] = None, + view_option: Optional[str] = None, + tag: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, + page: Optional[int] = 1, + user=Depends(get_verified_user), +): + + limit = PAGE_ITEM_COUNT + + page = max(1, page) + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + if view_option: + filter["view_option"] = view_option + if tag: + filter["tag"] = tag + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: + filter["user_id"] = user.id + + return Models.search_models(user.id, filter=filter, skip=skip, limit=limit) ########################### @@ -64,6 +93,30 @@ async def get_base_models(user=Depends(get_admin_user)): return Models.get_base_models() +########################### +# GetModelTags +########################### + + +@router.get("/tags", response_model=list[str]) +async def get_model_tags(user=Depends(get_verified_user)): + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + models = Models.get_models() + else: + models = Models.get_models_by_user_id(user.id) + + tags_set = set() + for model in models: + if model.meta: + meta = model.meta.model_dump() + for tag in meta.get("tags", []): + tags_set.add((tag.get("name"))) + + tags = [tag for tag in tags_set] + tags.sort() + return tags + + ############################ # CreateNewModel ############################ diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 64b0687afa..9606763b00 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -16,8 +16,8 @@ from urllib.parse import urlparse import aiohttp from aiocache import cached import requests -from urllib.parse import quote +from open_webui.utils.headers import include_user_info_headers from open_webui.models.chats import Chats from open_webui.models.users import UserModel @@ -82,22 +82,17 @@ async def send_get_request(url, key=None, user: UserModel = None): timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) try: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + headers = { + "Content-Type": "application/json", + **({"Authorization": f"Bearer {key}"} if key else {}), + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + async with session.get( url, - headers={ - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, + headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: return await response.json() @@ -133,28 +128,20 @@ async def send_post_request( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) + headers = { + "Content-Type": "application/json", + **({"Authorization": f"Bearer {key}"} if key else {}), + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + if metadata and metadata.get("chat_id"): + headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id") + r = await session.post( url, data=payload, - headers={ - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - **( - {"X-OpenWebUI-Chat-Id": metadata.get("chat_id")} - if metadata and metadata.get("chat_id") - else {} - ), - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, + headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) @@ -246,21 +233,16 @@ async def verify_connection( timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), ) as session: try: + headers = { + **({"Authorization": f"Bearer {key}"} if key else {}), + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + async with session.get( f"{url}/api/version", - headers={ - **({"Authorization": f"Bearer {key}"} if key else {}), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, + headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: if r.status != 200: @@ -469,22 +451,17 @@ async def get_ollama_tags( r = None try: + headers = { + **({"Authorization": f"Bearer {key}"} if key else {}), + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + r = requests.request( method="GET", url=f"{url}/api/tags", - headers={ - **({"Authorization": f"Bearer {key}"} if key else {}), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, + headers=headers, ) r.raise_for_status() @@ -838,23 +815,18 @@ async def copy_model( key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) try: + headers = { + "Content-Type": "application/json", + **({"Authorization": f"Bearer {key}"} if key else {}), + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + r = requests.request( method="POST", url=f"{url}/api/copy", - headers={ - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, + headers=headers, data=form_data.model_dump_json(exclude_none=True).encode(), ) r.raise_for_status() @@ -908,24 +880,19 @@ async def delete_model( key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) try: + headers = { + "Content-Type": "application/json", + **({"Authorization": f"Bearer {key}"} if key else {}), + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + r = requests.request( method="DELETE", url=f"{url}/api/delete", - data=json.dumps(form_data).encode(), - headers={ - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, + headers=headers, + data=form_data.model_dump_json(exclude_none=True).encode(), ) r.raise_for_status() @@ -973,24 +940,19 @@ async def show_model_info( key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) try: + headers = { + "Content-Type": "application/json", + **({"Authorization": f"Bearer {key}"} if key else {}), + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + r = requests.request( method="POST", url=f"{url}/api/show", - headers={ - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, - data=json.dumps(form_data).encode(), + headers=headers, + data=form_data.model_dump_json(exclude_none=True).encode(), ) r.raise_for_status() @@ -1064,23 +1026,18 @@ async def embed( form_data.model = form_data.model.replace(f"{prefix_id}.", "") try: + headers = { + "Content-Type": "application/json", + **({"Authorization": f"Bearer {key}"} if key else {}), + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + r = requests.request( method="POST", url=f"{url}/api/embed", - headers={ - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, + headers=headers, data=form_data.model_dump_json(exclude_none=True).encode(), ) r.raise_for_status() @@ -1151,23 +1108,18 @@ async def embeddings( form_data.model = form_data.model.replace(f"{prefix_id}.", "") try: + headers = { + "Content-Type": "application/json", + **({"Authorization": f"Bearer {key}"} if key else {}), + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + r = requests.request( method="POST", url=f"{url}/api/embeddings", - headers={ - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, + headers=headers, data=form_data.model_dump_json(exclude_none=True).encode(), ) r.raise_for_status() diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index cf75bc425a..a74a59ca1f 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -7,7 +7,6 @@ from typing import Optional import aiohttp from aiocache import cached import requests -from urllib.parse import quote from azure.identity import DefaultAzureCredential, get_bearer_token_provider @@ -50,6 +49,7 @@ from open_webui.utils.misc import ( from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access +from open_webui.utils.headers import include_user_info_headers log = logging.getLogger(__name__) @@ -67,21 +67,16 @@ async def send_get_request(url, key=None, user: UserModel = None): timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) try: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + headers = { + **({"Authorization": f"Bearer {key}"} if key else {}), + } + + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + async with session.get( url, - headers={ - **({"Authorization": f"Bearer {key}"} if key else {}), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, + headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: return await response.json() @@ -141,23 +136,13 @@ async def get_headers_and_cookies( if "openrouter.ai" in url else {} ), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - **( - {"X-OpenWebUI-Chat-Id": metadata.get("chat_id")} - if metadata and metadata.get("chat_id") - else {} - ), - } - if ENABLE_FORWARD_USER_INFO_HEADERS - else {} - ), } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + if metadata and metadata.get("chat_id"): + headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id") + token = None auth_type = config.get("auth_type") diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 80ef02caf8..600c33afa1 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -241,13 +241,14 @@ class SearchForm(BaseModel): async def get_status(request: Request): return { "status": True, - "chunk_size": request.app.state.config.CHUNK_SIZE, - "chunk_overlap": request.app.state.config.CHUNK_OVERLAP, - "template": request.app.state.config.RAG_TEMPLATE, - "embedding_engine": request.app.state.config.RAG_EMBEDDING_ENGINE, - "embedding_model": request.app.state.config.RAG_EMBEDDING_MODEL, - "reranking_model": request.app.state.config.RAG_RERANKING_MODEL, - "embedding_batch_size": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, + "CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP, + "RAG_TEMPLATE": request.app.state.config.RAG_TEMPLATE, + "RAG_EMBEDDING_ENGINE": request.app.state.config.RAG_EMBEDDING_ENGINE, + "RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL, + "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, + "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING, } @@ -255,9 +256,10 @@ async def get_status(request: Request): async def get_embedding_config(request: Request, user=Depends(get_admin_user)): return { "status": True, - "embedding_engine": request.app.state.config.RAG_EMBEDDING_ENGINE, - "embedding_model": request.app.state.config.RAG_EMBEDDING_MODEL, - "embedding_batch_size": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + "RAG_EMBEDDING_ENGINE": request.app.state.config.RAG_EMBEDDING_ENGINE, + "RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL, + "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING, "openai_config": { "url": request.app.state.config.RAG_OPENAI_API_BASE_URL, "key": request.app.state.config.RAG_OPENAI_API_KEY, @@ -294,18 +296,13 @@ class EmbeddingModelUpdateForm(BaseModel): openai_config: Optional[OpenAIConfigForm] = None ollama_config: Optional[OllamaConfigForm] = None azure_openai_config: Optional[AzureOpenAIConfigForm] = None - embedding_engine: str - embedding_model: str - embedding_batch_size: Optional[int] = 1 + RAG_EMBEDDING_ENGINE: str + RAG_EMBEDDING_MODEL: str + RAG_EMBEDDING_BATCH_SIZE: Optional[int] = 1 + ENABLE_ASYNC_EMBEDDING: Optional[bool] = True -@router.post("/embedding/update") -async def update_embedding_config( - request: Request, form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user) -): - log.info( - f"Updating embedding model: {request.app.state.config.RAG_EMBEDDING_MODEL} to {form_data.embedding_model}" - ) +def unload_embedding_model(request: Request): if request.app.state.config.RAG_EMBEDDING_ENGINE == "": # unloads current internal embedding model and clears VRAM cache request.app.state.ef = None @@ -318,9 +315,25 @@ async def update_embedding_config( if torch.cuda.is_available(): torch.cuda.empty_cache() + + +@router.post("/embedding/update") +async def update_embedding_config( + request: Request, form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user) +): + log.info( + f"Updating embedding model: {request.app.state.config.RAG_EMBEDDING_MODEL} to {form_data.RAG_EMBEDDING_MODEL}" + ) + unload_embedding_model(request) try: - request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.embedding_engine - request.app.state.config.RAG_EMBEDDING_MODEL = form_data.embedding_model + request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.RAG_EMBEDDING_ENGINE + request.app.state.config.RAG_EMBEDDING_MODEL = form_data.RAG_EMBEDDING_MODEL + request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = ( + form_data.RAG_EMBEDDING_BATCH_SIZE + ) + request.app.state.config.ENABLE_ASYNC_EMBEDDING = ( + form_data.ENABLE_ASYNC_EMBEDDING + ) if request.app.state.config.RAG_EMBEDDING_ENGINE in [ "ollama", @@ -354,10 +367,6 @@ async def update_embedding_config( form_data.azure_openai_config.version ) - request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = ( - form_data.embedding_batch_size - ) - request.app.state.ef = get_ef( request.app.state.config.RAG_EMBEDDING_ENGINE, request.app.state.config.RAG_EMBEDDING_MODEL, @@ -391,13 +400,15 @@ async def update_embedding_config( if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai" else None ), + enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING, ) return { "status": True, - "embedding_engine": request.app.state.config.RAG_EMBEDDING_ENGINE, - "embedding_model": request.app.state.config.RAG_EMBEDDING_MODEL, - "embedding_batch_size": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + "RAG_EMBEDDING_ENGINE": request.app.state.config.RAG_EMBEDDING_ENGINE, + "RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL, + "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING, "openai_config": { "url": request.app.state.config.RAG_OPENAI_API_BASE_URL, "key": request.app.state.config.RAG_OPENAI_API_KEY, @@ -453,18 +464,8 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, + "DOCLING_API_KEY": request.app.state.config.DOCLING_API_KEY, "DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS, - "DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR, - "DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR, - "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, - "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG, - "DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND, - "DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE, - "DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE, - "DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, - "DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE, - "DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL, - "DOCLING_PICTURE_DESCRIPTION_API": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API, "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, "MISTRAL_OCR_API_BASE_URL": request.app.state.config.MISTRAL_OCR_API_BASE_URL, @@ -642,18 +643,8 @@ class ConfigForm(BaseModel): TIKA_SERVER_URL: Optional[str] = None DOCLING_SERVER_URL: Optional[str] = None + DOCLING_API_KEY: Optional[str] = None DOCLING_PARAMS: Optional[dict] = None - DOCLING_DO_OCR: Optional[bool] = None - DOCLING_FORCE_OCR: Optional[bool] = None - DOCLING_OCR_ENGINE: Optional[str] = None - DOCLING_OCR_LANG: Optional[str] = None - DOCLING_PDF_BACKEND: Optional[str] = None - DOCLING_TABLE_MODE: Optional[str] = None - DOCLING_PIPELINE: Optional[str] = None - DOCLING_DO_PICTURE_DESCRIPTION: Optional[bool] = None - DOCLING_PICTURE_DESCRIPTION_MODE: Optional[str] = None - DOCLING_PICTURE_DESCRIPTION_LOCAL: Optional[dict] = None - DOCLING_PICTURE_DESCRIPTION_API: Optional[dict] = None DOCUMENT_INTELLIGENCE_ENDPOINT: Optional[str] = None DOCUMENT_INTELLIGENCE_KEY: Optional[str] = None MISTRAL_OCR_API_BASE_URL: Optional[str] = None @@ -831,68 +822,16 @@ async def update_rag_config( if form_data.DOCLING_SERVER_URL is not None else request.app.state.config.DOCLING_SERVER_URL ) + request.app.state.config.DOCLING_API_KEY = ( + form_data.DOCLING_API_KEY + if form_data.DOCLING_API_KEY is not None + else request.app.state.config.DOCLING_API_KEY + ) request.app.state.config.DOCLING_PARAMS = ( form_data.DOCLING_PARAMS if form_data.DOCLING_PARAMS is not None else request.app.state.config.DOCLING_PARAMS ) - request.app.state.config.DOCLING_DO_OCR = ( - form_data.DOCLING_DO_OCR - if form_data.DOCLING_DO_OCR is not None - else request.app.state.config.DOCLING_DO_OCR - ) - request.app.state.config.DOCLING_FORCE_OCR = ( - form_data.DOCLING_FORCE_OCR - if form_data.DOCLING_FORCE_OCR is not None - else request.app.state.config.DOCLING_FORCE_OCR - ) - request.app.state.config.DOCLING_OCR_ENGINE = ( - form_data.DOCLING_OCR_ENGINE - if form_data.DOCLING_OCR_ENGINE is not None - else request.app.state.config.DOCLING_OCR_ENGINE - ) - request.app.state.config.DOCLING_OCR_LANG = ( - form_data.DOCLING_OCR_LANG - if form_data.DOCLING_OCR_LANG is not None - else request.app.state.config.DOCLING_OCR_LANG - ) - request.app.state.config.DOCLING_PDF_BACKEND = ( - form_data.DOCLING_PDF_BACKEND - if form_data.DOCLING_PDF_BACKEND is not None - else request.app.state.config.DOCLING_PDF_BACKEND - ) - request.app.state.config.DOCLING_TABLE_MODE = ( - form_data.DOCLING_TABLE_MODE - if form_data.DOCLING_TABLE_MODE is not None - else request.app.state.config.DOCLING_TABLE_MODE - ) - request.app.state.config.DOCLING_PIPELINE = ( - form_data.DOCLING_PIPELINE - if form_data.DOCLING_PIPELINE is not None - else request.app.state.config.DOCLING_PIPELINE - ) - request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = ( - form_data.DOCLING_DO_PICTURE_DESCRIPTION - if form_data.DOCLING_DO_PICTURE_DESCRIPTION is not None - else request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION - ) - - request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE = ( - form_data.DOCLING_PICTURE_DESCRIPTION_MODE - if form_data.DOCLING_PICTURE_DESCRIPTION_MODE is not None - else request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE - ) - request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL = ( - form_data.DOCLING_PICTURE_DESCRIPTION_LOCAL - if form_data.DOCLING_PICTURE_DESCRIPTION_LOCAL is not None - else request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL - ) - request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API = ( - form_data.DOCLING_PICTURE_DESCRIPTION_API - if form_data.DOCLING_PICTURE_DESCRIPTION_API is not None - else request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API - ) - request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = ( form_data.DOCUMENT_INTELLIGENCE_ENDPOINT if form_data.DOCUMENT_INTELLIGENCE_ENDPOINT is not None @@ -1189,18 +1128,8 @@ async def update_rag_config( "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, + "DOCLING_API_KEY": request.app.state.config.DOCLING_API_KEY, "DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS, - "DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR, - "DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR, - "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, - "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG, - "DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND, - "DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE, - "DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE, - "DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, - "DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE, - "DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL, - "DOCLING_PICTURE_DESCRIPTION_API": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API, "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, "MISTRAL_OCR_API_BASE_URL": request.app.state.config.MISTRAL_OCR_API_BASE_URL, @@ -1467,10 +1396,13 @@ def save_docs_to_vector_db( ), ) - embeddings = embedding_function( - list(map(lambda x: x.replace("\n", " "), texts)), - prefix=RAG_EMBEDDING_CONTENT_PREFIX, - user=user, + # Run async embedding in sync context + embeddings = asyncio.run( + embedding_function( + list(map(lambda x: x.replace("\n", " "), texts)), + prefix=RAG_EMBEDDING_CONTENT_PREFIX, + user=user, + ) ) log.info(f"embeddings generated {len(embeddings)} for {len(texts)} items") @@ -1604,20 +1536,8 @@ def process_file( EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL, DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL, - DOCLING_PARAMS={ - "do_ocr": request.app.state.config.DOCLING_DO_OCR, - "force_ocr": request.app.state.config.DOCLING_FORCE_OCR, - "ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE, - "ocr_lang": request.app.state.config.DOCLING_OCR_LANG, - "pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND, - "table_mode": request.app.state.config.DOCLING_TABLE_MODE, - "pipeline": request.app.state.config.DOCLING_PIPELINE, - "do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, - "picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE, - "picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL, - "picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API, - **request.app.state.config.DOCLING_PARAMS, - }, + DOCLING_API_KEY=request.app.state.config.DOCLING_API_KEY, + DOCLING_PARAMS=request.app.state.config.DOCLING_PARAMS, PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES, DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, @@ -2262,7 +2182,7 @@ class QueryDocForm(BaseModel): @router.post("/query/doc") -def query_doc_handler( +async def query_doc_handler( request: Request, form_data: QueryDocForm, user=Depends(get_verified_user), @@ -2275,7 +2195,7 @@ def query_doc_handler( collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get( collection_name=form_data.collection_name ) - return query_doc_with_hybrid_search( + return await query_doc_with_hybrid_search( collection_name=form_data.collection_name, collection_result=collection_results[form_data.collection_name], query=form_data.query, @@ -2285,8 +2205,8 @@ def query_doc_handler( k=form_data.k if form_data.k else request.app.state.config.TOP_K, reranking_function=( ( - lambda sentences: request.app.state.RERANKING_FUNCTION( - sentences, user=user + lambda query, documents: request.app.state.RERANKING_FUNCTION( + query, documents, user=user ) ) if request.app.state.RERANKING_FUNCTION @@ -2307,11 +2227,12 @@ def query_doc_handler( user=user, ) else: + query_embedding = await request.app.state.EMBEDDING_FUNCTION( + form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user + ) return query_doc( collection_name=form_data.collection_name, - query_embedding=request.app.state.EMBEDDING_FUNCTION( - form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user - ), + query_embedding=query_embedding, k=form_data.k if form_data.k else request.app.state.config.TOP_K, user=user, ) @@ -2335,7 +2256,7 @@ class QueryCollectionsForm(BaseModel): @router.post("/query/collection") -def query_collection_handler( +async def query_collection_handler( request: Request, form_data: QueryCollectionsForm, user=Depends(get_verified_user), @@ -2344,7 +2265,7 @@ def query_collection_handler( if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and ( form_data.hybrid is None or form_data.hybrid ): - return query_collection_with_hybrid_search( + return await query_collection_with_hybrid_search( collection_names=form_data.collection_names, queries=[form_data.query], embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( @@ -2379,7 +2300,7 @@ def query_collection_handler( ), ) else: - return query_collection( + return await query_collection( collection_names=form_data.collection_names, queries=[form_data.query], embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( @@ -2461,7 +2382,7 @@ if ENV == "dev": @router.get("/ef/{text}") async def get_embeddings(request: Request, text: Optional[str] = "Hello World!"): return { - "result": request.app.state.EMBEDDING_FUNCTION( + "result": await request.app.state.EMBEDDING_FUNCTION( text, prefix=RAG_EMBEDDING_QUERY_PREFIX ) } diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 61b8fb13a4..f3069a093f 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -377,10 +377,13 @@ def get_current_user_by_api_key(request, api_key: str): detail=ERROR_MESSAGES.INVALID_TOKEN, ) - if not request.state.enable_api_keys or not has_permission( - user.id, - "features.api_keys", - request.app.state.config.USER_PERMISSIONS, + if not request.state.enable_api_keys or ( + user.role != "admin" + and not has_permission( + user.id, + "features.api_keys", + request.app.state.config.USER_PERMISSIONS, + ) ): raise HTTPException( status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 5095bb418b..323f93f450 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -24,6 +24,7 @@ from fastapi.responses import HTMLResponse from starlette.responses import Response, StreamingResponse, JSONResponse +from open_webui.utils.misc import is_string_allowed from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.chats import Chats from open_webui.models.folders import Folders @@ -993,37 +994,32 @@ async def chat_completion_files_handler( queries = [get_last_user_message(body["messages"])] try: - # Offload get_sources_from_items to a separate thread - loop = asyncio.get_running_loop() - with ThreadPoolExecutor() as executor: - sources = await loop.run_in_executor( - executor, - lambda: get_sources_from_items( - request=request, - items=files, - queries=queries, - embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( - query, prefix=prefix, user=user - ), - k=request.app.state.config.TOP_K, - reranking_function=( - ( - lambda query, documents: request.app.state.RERANKING_FUNCTION( - query, documents, user=user - ) - ) - if request.app.state.RERANKING_FUNCTION - else None - ), - k_reranker=request.app.state.config.TOP_K_RERANKER, - r=request.app.state.config.RELEVANCE_THRESHOLD, - hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT, - hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, - full_context=all_full_context - or request.app.state.config.RAG_FULL_CONTEXT, - user=user, - ), - ) + # Directly await async get_sources_from_items (no thread needed - fully async now) + sources = await get_sources_from_items( + request=request, + items=files, + queries=queries, + embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( + query, prefix=prefix, user=user + ), + k=request.app.state.config.TOP_K, + reranking_function=( + ( + lambda query, documents: request.app.state.RERANKING_FUNCTION( + query, documents, user=user + ) + ) + if request.app.state.RERANKING_FUNCTION + else None + ), + k_reranker=request.app.state.config.TOP_K_RERANKER, + r=request.app.state.config.RELEVANCE_THRESHOLD, + hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT, + hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, + full_context=all_full_context + or request.app.state.config.RAG_FULL_CONTEXT, + user=user, + ) except Exception as e: log.exception(e) @@ -1130,7 +1126,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): pass event_emitter = get_event_emitter(metadata) - event_call = get_event_call(metadata) + event_caller = get_event_call(metadata) oauth_token = None try: @@ -1144,14 +1140,13 @@ async def process_chat_payload(request, form_data, user, metadata, model): extra_params = { "__event_emitter__": event_emitter, - "__event_call__": event_call, + "__event_call__": event_caller, "__user__": user.model_dump() if isinstance(user, UserModel) else {}, "__metadata__": metadata, + "__oauth_token__": oauth_token, "__request__": request, "__model__": model, - "__oauth_token__": oauth_token, } - # Initialize events to store additional event to be sent to the client # Initialize contexts and citation if getattr(request.state, "direct", False) and hasattr(request.state, "model"): @@ -1414,6 +1409,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): headers=headers if headers else None, ) + function_name_filter_list = mcp_server_connection.get( + "function_name_filter_list", None + ) tool_specs = await mcp_clients[server_id].list_tool_specs() for tool_spec in tool_specs: @@ -1426,6 +1424,15 @@ async def process_chat_payload(request, form_data, user, metadata, model): return tool_function + if function_name_filter_list and isinstance( + function_name_filter_list, list + ): + if not is_string_allowed( + tool_spec["name"], function_name_filter_list + ): + # Skip this function + continue + tool_function = make_tool_function( mcp_clients[server_id], tool_spec["name"] ) @@ -1466,6 +1473,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): "__files__": metadata.get("files", []), }, ) + if mcp_tools_dict: tools_dict = {**tools_dict, **mcp_tools_dict} diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index ce16691365..466e235598 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -27,6 +27,45 @@ def deep_update(d, u): return d +def get_allow_block_lists(filter_list): + allow_list = [] + block_list = [] + + if filter_list: + for d in filter_list: + if d.startswith("!"): + # Domains starting with "!" → blocked + block_list.append(d[1:]) + else: + # Domains starting without "!" → allowed + allow_list.append(d) + + return allow_list, block_list + + +def is_string_allowed(string: str, filter_list: Optional[list[str]] = None) -> bool: + """ + Checks if a string is allowed based on the provided filter list. + :param string: The string to check (e.g., domain or hostname). + :param filter_list: List of allowed/blocked strings. Strings starting with "!" are blocked. + :return: True if the string is allowed, False otherwise. + """ + if not filter_list: + return True + + allow_list, block_list = get_allow_block_lists(filter_list) + # If allow list is non-empty, require domain to match one of them + if allow_list: + if not any(string.endswith(allowed) for allowed in allow_list): + return False + + # Block list always removes matches + if any(string.endswith(blocked) for blocked in block_list): + return False + + return True + + def get_message_list(messages_map, message_id): """ Reconstructs a list of messages in order up to the specified message_id. diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 5add660bdf..77a2ebd46e 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -53,6 +53,7 @@ from open_webui.config import ( OAUTH_ADMIN_ROLES, OAUTH_ALLOWED_DOMAINS, OAUTH_UPDATE_PICTURE_ON_LOGIN, + OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID, WEBHOOK_URL, JWT_EXPIRES_IN, AppConfig, @@ -1273,11 +1274,13 @@ class OAuthManager: client = self.get_client(provider) auth_params = {} + if client: - if hasattr(client, "client_id"): + if ( + hasattr(client, "client_id") + and OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID + ): auth_params["client_id"] = client.client_id - if hasattr(client, "client_secret"): - auth_params["client_secret"] = client.client_secret try: token = await client.authorize_access_token(request, **auth_params) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index fb623ed332..ecdf7187e4 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -34,6 +34,7 @@ from langchain_core.utils.function_calling import ( ) +from open_webui.utils.misc import is_string_allowed from open_webui.models.tools import Tools from open_webui.models.users import UserModel from open_webui.utils.plugin import load_tool_module_by_id @@ -149,8 +150,20 @@ async def get_tools( ) specs = tool_server_data.get("specs", []) + function_name_filter_list = tool_server_connection.get( + "function_name_filter_list", None + ) + for spec in specs: function_name = spec["name"] + if function_name_filter_list and isinstance( + function_name_filter_list, list + ): + if not is_string_allowed( + function_name, function_name_filter_list + ): + # Skip this function + continue auth_type = tool_server_connection.get("auth_type", "bearer") diff --git a/backend/requirements.txt b/backend/requirements.txt index 9936eeaa95..db32255a89 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -41,7 +41,7 @@ mcp==1.21.2 openai anthropic -google-genai==1.38.0 +google-genai==1.52.0 google-generativeai==0.8.5 langchain==0.3.27 @@ -106,6 +106,7 @@ google-auth-oauthlib googleapis-common-protos==1.70.0 google-cloud-storage==2.19.0 +## Databases pymongo psycopg2-binary==2.9.10 pgvector==0.4.1 diff --git a/package-lock.json b/package-lock.json index aca269f401..94cde05b1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.6.36", + "version": "0.6.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.6.36", + "version": "0.6.38", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index 9065bda0ce..3ee4b5680d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.6.36", + "version": "0.6.38", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/pyproject.toml b/pyproject.toml index 2d3f6a2835..85a8044e3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,9 +37,6 @@ dependencies = [ "pycrdt==0.12.25", "redis", - "PyMySQL==1.1.1", - "boto3==1.40.5", - "APScheduler==3.10.4", "RestrictedPython==8.0", @@ -51,7 +48,7 @@ dependencies = [ "openai", "anthropic", - "google-genai==1.38.0", + "google-genai==1.52.0", "google-generativeai==0.8.5", "langchain==0.3.27", @@ -60,6 +57,8 @@ dependencies = [ "fake-useragent==2.2.0", "chromadb==1.0.20", "opensearch-py==2.8.0", + "PyMySQL==1.1.1", + "boto3==1.40.5", "transformers", "sentence-transformers==5.1.1", diff --git a/src/app.html b/src/app.html index 9333dc8ba3..3fe87514f2 100644 --- a/src/app.html +++ b/src/app.html @@ -174,72 +174,72 @@ --> - - + @keyframes pulse { + 50% { + opacity: 0.65; + } + } + + .animate-pulse-fast { + animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + + diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index c548a71dc2..010c80a56f 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -65,15 +65,7 @@ export const unarchiveAllChats = async (token: string) => { return res; }; -export const importChat = async ( - token: string, - chat: object, - meta: object | null, - pinned?: boolean, - folderId?: string | null, - createdAt: number | null = null, - updatedAt: number | null = null -) => { +export const importChats = async (token: string, chats: object[]) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/chats/import`, { @@ -84,12 +76,7 @@ export const importChat = async ( authorization: `Bearer ${token}` }, body: JSON.stringify({ - chat: chat, - meta: meta ?? {}, - pinned: pinned, - folder_id: folderId, - created_at: createdAt ?? null, - updated_at: updatedAt ?? null + chats }) }) .then(async (res) => { diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts index 0faa547141..535adbd5f6 100644 --- a/src/lib/apis/folders/index.ts +++ b/src/lib/apis/folders/index.ts @@ -239,10 +239,13 @@ export const updateFolderItemsById = async (token: string, id: string, items: Fo return res; }; -export const deleteFolderById = async (token: string, id: string) => { +export const deleteFolderById = async (token: string, id: string, deleteContents: boolean) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}`, { + const searchParams = new URLSearchParams(); + searchParams.append('delete_contents', deleteContents ? 'true' : 'false'); + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}?${searchParams.toString()}`, { method: 'DELETE', headers: { Accept: 'application/json', diff --git a/src/lib/apis/models/index.ts b/src/lib/apis/models/index.ts index 6f6df2c681..91dc9aac32 100644 --- a/src/lib/apis/models/index.ts +++ b/src/lib/apis/models/index.ts @@ -2,7 +2,7 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; import { models, config, type Model } from '$lib/stores'; import { get } from 'svelte/store'; export const getModels = async (token: string = ''): Promise => { - const lang = get(config)?.default_locale || 'de'; + const lang = get(config)?.default_locale || 'en'; try { const res = await fetch(`${WEBUI_API_BASE_URL}/models/`, { method: 'GET', @@ -26,11 +26,70 @@ export const getModels = async (token: string = ''): Promise => } }; -export const getModelItems = async (token: string = '') => { - const lang = get(config)?.default_locale || 'de'; +export const getModelItems = async ( + token: string = '', + query, + viewOption, + selectedTag, + orderBy, + direction, + page +) => { + const lang = get(config)?.default_locale || 'en'; let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/models/list`, { + const searchParams = new URLSearchParams(); + if (query) { + searchParams.append('query', query); + } + if (viewOption) { + searchParams.append('view_option', viewOption); + } + if (selectedTag) { + searchParams.append('tag', selectedTag); + } + if (orderBy) { + searchParams.append('order_by', orderBy); + } + if (direction) { + searchParams.append('direction', direction); + } + if (page) { + searchParams.append('page', page.toString()); + } + + const res = await fetch(`${WEBUI_API_BASE_URL}/models/list?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getModelTags = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/models/tags`, { method: 'GET', headers: { Accept: 'application/json', diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 5515910a18..2b639b3e64 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -47,6 +47,7 @@ let key = ''; let headers = ''; + let functionNameFilterList = []; let accessControl = {}; let id = ''; @@ -284,6 +285,7 @@ headers = JSON.stringify(_headers, null, 2); } catch (error) { toast.error($i18n.t('Headers must be a valid JSON object')); + loading = false; return; } } @@ -302,7 +304,7 @@ key, config: { enable: enable, - + function_name_filter_list: functionNameFilterList, access_control: accessControl }, info: { @@ -332,9 +334,11 @@ id = ''; name = ''; description = ''; + oauthClientInfo = null; enable = true; + functionNameFilterList = []; accessControl = null; }; @@ -358,6 +362,7 @@ oauthClientInfo = connection.info?.oauth_client_info ?? null; enable = connection.config?.enable ?? true; + functionNameFilterList = connection.config?.function_name_filter_list ?? []; accessControl = connection.config?.access_control ?? null; } }; @@ -792,6 +797,25 @@ +
+ + +
+ +
+
+
diff --git a/src/lib/components/admin/Evaluations/Feedbacks.svelte b/src/lib/components/admin/Evaluations/Feedbacks.svelte index c47524eef4..16b99108f8 100644 --- a/src/lib/components/admin/Evaluations/Feedbacks.svelte +++ b/src/lib/components/admin/Evaluations/Feedbacks.svelte @@ -288,7 +288,7 @@ class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition" on:click={() => openFeedbackModal(feedback)} > - +
@@ -306,7 +306,7 @@
{#if feedback.data?.sibling_model_ids} -
+
{feedback.data?.model_id}
@@ -352,7 +352,7 @@ {dayjs(feedback.updated_at * 1000).fromNow()} - e.stopPropagation()}> + e.stopPropagation()}> { deleteFeedbackHandler(feedback.id); diff --git a/src/lib/components/admin/Evaluations/Leaderboard.svelte b/src/lib/components/admin/Evaluations/Leaderboard.svelte index 948551cde2..5dae9a28db 100644 --- a/src/lib/components/admin/Evaluations/Leaderboard.svelte +++ b/src/lib/components/admin/Evaluations/Leaderboard.svelte @@ -530,7 +530,7 @@ {model.rating} - +
{#if model.stats.won === '-'} - @@ -543,7 +543,7 @@
- +
{#if model.stats.lost === '-'} - diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 83369ac993..8186430a92 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -38,9 +38,11 @@ let showResetUploadDirConfirm = false; let showReindexConfirm = false; - let embeddingEngine = ''; - let embeddingModel = ''; - let embeddingBatchSize = 1; + let RAG_EMBEDDING_ENGINE = ''; + let RAG_EMBEDDING_MODEL = ''; + let RAG_EMBEDDING_BATCH_SIZE = 1; + let ENABLE_ASYNC_EMBEDDING = true; + let rerankingModel = ''; let OpenAIUrl = ''; @@ -64,7 +66,7 @@ let RAGConfig = null; const embeddingModelUpdateHandler = async () => { - if (embeddingEngine === '' && embeddingModel.split('/').length - 1 > 1) { + if (RAG_EMBEDDING_ENGINE === '' && RAG_EMBEDDING_MODEL.split('/').length - 1 > 1) { toast.error( $i18n.t( 'Model filesystem path detected. Model shortname is required for update, cannot continue.' @@ -72,7 +74,7 @@ ); return; } - if (embeddingEngine === 'ollama' && embeddingModel === '') { + if (RAG_EMBEDDING_ENGINE === 'ollama' && RAG_EMBEDDING_MODEL === '') { toast.error( $i18n.t( 'Model filesystem path detected. Model shortname is required for update, cannot continue.' @@ -81,7 +83,7 @@ return; } - if (embeddingEngine === 'openai' && embeddingModel === '') { + if (RAG_EMBEDDING_ENGINE === 'openai' && RAG_EMBEDDING_MODEL === '') { toast.error( $i18n.t( 'Model filesystem path detected. Model shortname is required for update, cannot continue.' @@ -91,20 +93,26 @@ } if ( - embeddingEngine === 'azure_openai' && + RAG_EMBEDDING_ENGINE === 'azure_openai' && (AzureOpenAIKey === '' || AzureOpenAIUrl === '' || AzureOpenAIVersion === '') ) { toast.error($i18n.t('OpenAI URL/Key required.')); return; } - console.debug('Update embedding model attempt:', embeddingModel); + console.debug('Update embedding model attempt:', { + RAG_EMBEDDING_ENGINE, + RAG_EMBEDDING_MODEL, + RAG_EMBEDDING_BATCH_SIZE, + ENABLE_ASYNC_EMBEDDING + }); updateEmbeddingModelLoading = true; const res = await updateEmbeddingConfig(localStorage.token, { - embedding_engine: embeddingEngine, - embedding_model: embeddingModel, - embedding_batch_size: embeddingBatchSize, + RAG_EMBEDDING_ENGINE: RAG_EMBEDDING_ENGINE, + RAG_EMBEDDING_MODEL: RAG_EMBEDDING_MODEL, + RAG_EMBEDDING_BATCH_SIZE: RAG_EMBEDDING_BATCH_SIZE, + ENABLE_ASYNC_EMBEDDING: ENABLE_ASYNC_EMBEDDING, ollama_config: { key: OllamaKey, url: OllamaUrl @@ -151,26 +159,6 @@ toast.error($i18n.t('Docling Server URL required.')); return; } - if ( - RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' && - RAGConfig.DOCLING_DO_OCR && - ((RAGConfig.DOCLING_OCR_ENGINE === '' && RAGConfig.DOCLING_OCR_LANG !== '') || - (RAGConfig.DOCLING_OCR_ENGINE !== '' && RAGConfig.DOCLING_OCR_LANG === '')) - ) { - toast.error( - $i18n.t('Both Docling OCR Engine and Language(s) must be provided or both left empty.') - ); - return; - } - if ( - RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' && - RAGConfig.DOCLING_DO_OCR === false && - RAGConfig.DOCLING_FORCE_OCR === true - ) { - toast.error($i18n.t('In order to force OCR, performing OCR must be enabled.')); - return; - } - if ( RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker' && RAGConfig.DATALAB_MARKER_ADDITIONAL_CONFIG && @@ -238,12 +226,6 @@ ALLOWED_FILE_EXTENSIONS: RAGConfig.ALLOWED_FILE_EXTENSIONS.split(',') .map((ext) => ext.trim()) .filter((ext) => ext !== ''), - DOCLING_PICTURE_DESCRIPTION_LOCAL: JSON.parse( - RAGConfig.DOCLING_PICTURE_DESCRIPTION_LOCAL || '{}' - ), - DOCLING_PICTURE_DESCRIPTION_API: JSON.parse( - RAGConfig.DOCLING_PICTURE_DESCRIPTION_API || '{}' - ), DOCLING_PARAMS: typeof RAGConfig.DOCLING_PARAMS === 'string' && RAGConfig.DOCLING_PARAMS.trim() !== '' ? JSON.parse(RAGConfig.DOCLING_PARAMS) @@ -260,9 +242,10 @@ const embeddingConfig = await getEmbeddingConfig(localStorage.token); if (embeddingConfig) { - embeddingEngine = embeddingConfig.embedding_engine; - embeddingModel = embeddingConfig.embedding_model; - embeddingBatchSize = embeddingConfig.embedding_batch_size ?? 1; + RAG_EMBEDDING_ENGINE = embeddingConfig.RAG_EMBEDDING_ENGINE; + RAG_EMBEDDING_MODEL = embeddingConfig.RAG_EMBEDDING_MODEL; + RAG_EMBEDDING_BATCH_SIZE = embeddingConfig.RAG_EMBEDDING_BATCH_SIZE ?? 1; + ENABLE_ASYNC_EMBEDDING = embeddingConfig.ENABLE_ASYNC_EMBEDDING ?? true; OpenAIKey = embeddingConfig.openai_config.key; OpenAIUrl = embeddingConfig.openai_config.url; @@ -281,16 +264,6 @@ const config = await getRAGConfig(localStorage.token); config.ALLOWED_FILE_EXTENSIONS = (config?.ALLOWED_FILE_EXTENSIONS ?? []).join(', '); - config.DOCLING_PICTURE_DESCRIPTION_LOCAL = JSON.stringify( - config.DOCLING_PICTURE_DESCRIPTION_LOCAL ?? {}, - null, - 2 - ); - config.DOCLING_PICTURE_DESCRIPTION_API = JSON.stringify( - config.DOCLING_PICTURE_DESCRIPTION_API ?? {}, - null, - 2 - ); config.DOCLING_PARAMS = typeof config.DOCLING_PARAMS === 'object' ? JSON.stringify(config.DOCLING_PARAMS ?? {}, null, 2) @@ -589,174 +562,19 @@
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling'} -
+
+
-
-
-
- {$i18n.t('Perform OCR')} -
-
- -
-
-
- {#if RAGConfig.DOCLING_DO_OCR} -
- - -
- {/if} -
-
-
- {$i18n.t('Force OCR')} -
-
- -
-
-
-
-
- - {$i18n.t('PDF Backend')} - -
-
- -
-
-
-
- - {$i18n.t('Table Mode')} - -
-
- -
-
-
-
- - {$i18n.t('Pipeline')} - -
-
- -
-
-
-
-
- {$i18n.t('Describe Pictures in Documents')} -
-
- -
-
-
- {#if RAGConfig.DOCLING_DO_PICTURE_DESCRIPTION} -
-
- - {$i18n.t('Picture Description Mode')} - -
-
- -
-
- - {#if RAGConfig.DOCLING_PICTURE_DESCRIPTION_MODE === 'local'} -
-
-
- {$i18n.t('Picture Description Local Config')} -
-
- -