diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ae2e2529c3..7f603cb10c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -73,4 +73,4 @@ ### Contributor License Agreement -By submitting this pull request, I confirm that I have read and fully agree to the [CONTRIBUTOR_LICENSE_AGREEMENT](CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms. \ No newline at end of file +By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms. diff --git a/CHANGELOG.md b/CHANGELOG.md index 91da40b9e5..fcf7d51555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,120 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.9] - 2025-05-10 + +### Added + +- 📝 **Edit Attached Images/Files in Messages**: You can now easily edit your sent messages by removing attached files—streamlining document management, correcting mistakes on the fly, and keeping your chats clutter-free. +- 🚨 **Clear Alerts for Private Task Models**: When interacting with private task models, the UI now clearly alerts you—making it easier to understand resource availability and access, reducing confusion during workflow setup. + +### Fixed + +- 🛡️ **Confirm Dialog Focus Trap Reliability**: The focus now stays correctly within confirmation dialogs, ensuring keyboard navigation and accessibility is seamless and preventing accidental operations—especially helpful during critical or rapid workflows. +- 💬 **Temporary Chat Admin Controls & Session Cleanliness**: Admins are now able to properly enable temporary chat mode without errors, and previous session prompts or tool selections no longer carry over—delivering a fresh, predictable, and consistent temporary chat experience every time. +- 🤖 **External Reranker Integration Functionality Restored**: External reranker integrations now work correctly, allowing you to fully leverage advanced ranking services for sharper, more relevant search results in your RAG and knowledge base workflows. + +## [0.6.8] - 2025-05-10 + +### Added + +- 🏆 **External Reranker Support for Knowledge Base Search**: Supercharge your Retrieval-Augmented Generation (RAG) workflows with the new External Reranker integration; easily plug in advanced reranking services via the UI to deliver sharper and more relevant search results, accelerating research and insight discovery. +- 📤 **Unstylized PDF Export Option (Reduced File Size)**: When exporting chat transcripts or documents, you can now choose an unstylized PDF export for snappier downloads, minimal file size, and clean data archiving—perfect for large-scale storage or sharing. +- 📝 **Vazirmatn Font for Persian & Arabic**: Arabic and Persian users will now see their text beautifully rendered with the specialized Vazirmatn font for an improved localized reading experience. +- 🏷️ **SharePoint Tenant ID Support for OneDrive**: You can now specify a SharePoint tenant ID in OneDrive settings for seamless authentication and granular enterprise integration. +- 👤 **Refresh OAuth Profile Picture**: Your OAuth profile picture now updates in real-time, ensuring your presence and avatar always match your latest identity across integrated platforms. +- 🔧 **Milvus Configuration Improvements**: Configure index and metric types for Milvus directly within settings; take full control of your vector database for more accurate and robust AI search experiences. +- 🛡️ **S3 Tagging Toggle for Compatibility**: Optional S3 tagging via an environment toggle grants full compatibility with all storage backends—including those that don’t support tagging like Cloudflare R2—ensuring error-free attachment and document management. +- 👨‍🦯 **Icon Button Accessibility Improvements**: Key interactive icon-buttons now include aria-labels and ARIA descriptions, so screen readers provide precise guidance about what action each button performs for improved accessibility. +- ♿ **Enhanced Accessibility with Modal Focus Trap**: Modal dialogs and pop-ups now feature a focus trap and improved ARIA roles, ensuring seamless navigation and screen reader support—making the interface friendlier for everyone, including keyboard and assistive tech users. +- 🏃 **Improved Admin User List Loading Indicator**: The user list loading experience is now clearer and more responsive in the admin panel. +- 🧑‍🤝‍🧑 **Larger Admin User List Page Size**: Admins can now manage up to 30 users per page in the admin interface, drastically reducing pagination and making large user teams easier and faster to manage. +- 🌠 **Default Code Interpreter Prompt Clarified**: The built-in code interpreter prompt is now more explicit, preventing AI from wrapping code in Markdown blocks when not needed—ensuring properly formatted code runs as intended every time. +- 🧾 **Improved Default Title Generation Prompt Template**: Title generation now uses a robust template for reliable JSON output, improving chat organization and searchability. +- 🔗 **Support Jupyter Notebooks with Non-Root Base URLs**: Notebook-based code execution now supports non-root deployed Jupyter servers, granting full flexibility for hybrid or multi-user setups. +- 📰 **UI Scrollbar Always Visible for Overflow Tools**: When available tools overflow the display, the scrollbar is now always visible and there’s a handy "show all" toggle, making navigation of large toolsets snappier and more intuitive. +- 🛠️ **General Backend Refactoring for Stability**: Multiple under-the-hood improvements have been made across backend components, ensuring smoother performance, fewer errors, and a more reliable overall experience for all users. +- 🚀 **Optimized Web Search for Faster Results**: Web search speed and performance have been significantly enhanced, delivering answers and sources in record time to accelerate your research-heavy workflows. +- 💡 **More Supported Languages**: Expanded language support ensures an even wider range of users can enjoy an intuitive and natural interface in their native tongue. + +### Fixed + +- 🏃‍♂️ **Exhausting Workers in Nginx Reverse Proxy Due to Websocket Fix**: Websocket sessions are now fully compatible behind Nginx, eliminating worker exhaustion and restoring 24/7 reliability for real-time chats even in complex deployments. +- 🎤 **Audio Transcription Issue with OpenAI Resolved**: OpenAI-based audio transcription now handles WebM and newer formats without error, ensuring seamless voice-to-text workflows every time. +- 👉 **Message Input RTL Issue Fixed**: The chat message input now displays correctly for right-to-left languages, creating a flawless typing and reading experience for Arabic, Hebrew, and more. +- 🀄 **Katex: Proper Rendering of Chinese Characters Next to Math**: Math formulas now render perfectly even when directly adjacent to Chinese (CJK) characters, improving visual clarity for multilingual teams and cross-language documents. +- 🔂 **Duplicate Web Search URLs Eliminated**: Search results now reliably filter out URL duplicates, so your knowledge and search citations are always clean, trimmed, and easy to review. +- 📄 **Markdown Rendering Fixed in Knowledge Bases**: Markdown is now displayed correctly within knowledge bases, enabling better formatting and clarity of information-rich files. +- 🗂️ **LDAP Import/Loading Issue Resolved**: LDAP user imports process correctly, ensuring smooth onboarding and access without interruption. +- 🌎 **Pinecone Batch Operations and Async Safety**: All Pinecone operations (batch insert, upsert, delete) now run efficiently and safely in an async environment, boosting performance and preventing slowdowns in large-scale RAG jobs. + +## [0.6.7] - 2025-05-07 + +### Added + +- 🌐 **Custom Azure TTS API URL Support Added**: You can now define a custom Azure Text-to-Speech endpoint—enabling flexibility for enterprise deployments and regional compliance. +- ⚙️ **TOOL_SERVER_CONNECTIONS Environment Variable Suppor**: Easily configure and deploy tool servers via environment variables, streamlining setup and enabling faster enterprise provisioning. +- 👥 **Enhanced OAuth Group Handling as String or List**: OAuth group data can now be passed as either a list or a comma-separated string, improving compatibility with varied identity provider formats and reducing onboarding friction. + +### Fixed + +- 🧠 **Embedding with Ollama Proxy Endpoints Restored**: Fixed an issue where missing API config broke embedding for proxied Ollama models—ensuring consistent performance and compatibility. +- 🔐 **OIDC OAuth Login Issue Resolved**: Users can once again sign in seamlessly using OpenID Connect-based OAuth, eliminating login interruptions and improving reliability. +- 📝 **Notes Feature Access Fixed for Non-Admins**: Fixed an issue preventing non-admin users from accessing the Notes feature, restoring full cross-role collaboration capabilities. +- 🖼️ **Tika Loader Image Extraction Problem Resolved**: Ensured TikaLoader now processes 'extract_images' parameter correctly, restoring complete file extraction functionality in document workflows. +- 🎨 **Automatic1111 Image Model Setting Applied Properly**: Fixed an issue where switching to a specific image model via the UI wasn’t reflected in generation, re-enabling full visual creativity control. +- 🏷️ **Multiple XML Tags in Messages Now Parsed Correctly**: Fixed parsing issues when messages included multiple XML-style tags, ensuring clean and unbroken rendering of rich content in chats. +- 🖌️ **OpenAI Image Generation Issues Resolved**: Resolved broken image output when using OpenAI’s image generation, ensuring fully functional visual creation workflows. +- 🔎 **Tool Server Settings UI Privacy Restored**: Prevented restricted users from accessing tool server settings via search—restoring tight permissions control and safeguarding sensitive configurations. +- 🎧 **WebM Audio Transcription Now Supported**: Fixed an issue where WebM files failed during audio transcription—these formats are now fully supported, ensuring smoother voice note workflows and broader file compatibility. + +## [0.6.6] - 2025-05-05 + +### Added + +- 📝 **AI-Enhanced Notes (With Audio Transcription)**: Effortlessly create notes, attach meeting or voice audio, and let the AI instantly enhance, summarize, or refine your notes using audio transcriptions—making your documentation smarter, cleaner, and more insightful with minimal effort. +- 🔊 **Meeting Audio Recording & Import**: Seamlessly record audio from your meetings or capture screen audio and attach it to your notes—making it easier to revisit, annotate, and extract insights from important discussions. +- 📁 **Import Markdown Notes Effortlessly**: Bring your existing knowledge library into Open WebUI by importing your Markdown notes, so you can leverage all advanced note management and AI features right away. +- 👥 **Notes Permissions by User Group**: Fine-tune access and editing rights for notes based on user roles or groups, so you can delegate writing or restrict sensitive information as needed. +- ☁️ **OneDrive & SharePoint Integration**: Keep your content in sync by connecting notes and files directly with OneDrive or SharePoint—unlocking fast enterprise import/export and seamless collaboration with your existing workflows. +- 🗂️ **Paginated User List in Admin Panel**: Effortlessly manage and search through large teams via the new paginated user list—saving time and streamlining user administration in big organizations. +- 🕹️ **Granular Chat Share & Export Permissions**: Enjoy enhanced control over who can share or export chats, enabling tighter governance and privacy in team and enterprise settings. +- 🛑 **User Role Change Confirmation Dialog**: Reduce accidental privilege changes with a required confirmation step before updating user roles—improving security and preventing costly mistakes in team management. +- 🚨 **Audit Log for Failed Login Attempts**: Quickly detect unauthorized access attempts or troubleshoot user login problems with detailed logs of failed authentication right in the audit trail. +- 💡 **Dedicated 'Generate Title' Button for Chats**: Swiftly organize every conversation—tap the new button to let AI create relevant, clear titles for all your chats, saving time and reducing clutter. +- 💬 **Notification Sound Always-On Option**: Take control of your notifications by setting sound alerts to always play—helping you stay on top of important updates in busy environments. +- 🆔 **S3 File Tagging Support**: Uploaded files to S3 now include tags for better organization, searching, and integration with your file management policies. +- 🛡️ **OAuth Blocked Groups Support**: Gain more control over group-based access by explicitly blocking specified OAuth groups—ideal for complex identity or security requirements. +- 🚀 **Optimized Faster Web Search & Multi-Threaded Queries**: Enjoy dramatically faster web search and RAG (retrieval augmented generation) with revamped multi-threaded search—get richer, more accurate results in less time. +- 🔍 **All-Knowledge Parallel Search**: Searches across your entire knowledge base now happen in parallel even in non-hybrid mode, speeding up responses and improving knowledge accuracy for every question. +- 🌐 **New Firecrawl & Yacy Web Search Integrations**: Expand your world of information with two new advanced search engines—Firecrawl for deeper web insight and Yacy for decentralized, privacy-friendly search capabilities. +- 🧠 **Configurable Docling OCR Engine & Language**: Use environment variables to fine-tune Docling OCR engine and supported languages for smarter, more tailored document extraction and RAG workflows. +- 🗝️ **Enhanced Sentence Transformers Configuration**: Added new environment variables for easier set up and advanced customization of Sentence Transformers—ensuring best fit for your embedding needs. +- 🌲 **Pinecone Vector Database Integration**: Index, search, and manage knowledge at enterprise scale with full native support for Pinecone as your vector database—effortlessly handle even the biggest document sets. +- 🔄 **Automatic Requirements Installation for Tools & Functions**: Never worry about lost dependencies on restart—external function and tool requirements are now auto-installed at boot, ensuring tools always “just work.” +- 🔒 **Automatic Sign-Out on Token Expiry**: Security is smarter—users are now automatically logged out if their authentication token expires, protecting sensitive content and ensuring compliance without disruption. +- 🎬 **Automatic YouTube Embed Detection**: Paste YouTube links and see instant in-chat video embeds—no more manual embedding, making knowledge sharing and media consumption even easier for every team. +- 🔄 **Expanded Language & Locale Support**: Translations for Danish, French, Russian, Traditional Chinese, Simplified Chinese, Thai, Catalan, German, and Korean have been upgraded, offering smoother, more natural user experiences across the platform. + +### Fixed + +- 🔒 **Tighter HTML Token Security**: HTML rendering is now restricted to admin-uploaded tokens only, reducing any risk of XSS and keeping your data safe. +- 🔐 **Refined HTML Security and Token Handling**: Further hardened how HTML tokens and content are handled, guaranteeing even stronger resistance to security vulnerabilities and attacks. +- 🔏 **Correct Model Usage with Ollama Proxy Prefixes**: Enhanced model reference handling so proxied models in Ollama always download and run correctly—even when using custom prefixes. +- 📥 **Video File Upload Handling**: Prevented video files from being misclassified as text, fixing bugs with uploads and ensuring media files work as expected. +- 🔄 **No More Dependent WebSocket Sequential Delays**: Streamlined WebSocket operation to prevent delays and maintain snappy real-time collaboration, especially in multi-user environments. +- 🛠️ **More Robust Action Module Execution**: Multiple actions in a module now trigger as designed, increasing automation and scripting flexibility. +- 📧 **Notification Webhooks**: Ensured that notification webhooks are always sent for user events, even when the user isn’t currently active. +- 🗂️ **Smarter Knowledge Base Reindexing**: Knowledge reindexing continues even when corrupt or missing collections are encountered, keeping your search features running reliably. +- 🏷️ **User Import with Profile Images**: When importing users, their profile images now come along—making onboarding and collaboration visually clearer from day one. +- 💬 **OpenAI o-Series Universal Support**: All OpenAI o-series models are now seamlessly recognized and supported, unlocking more advanced capabilities and model choices for every workflow. + +### Changed + +- 📜 **Custom License Update & Contributor Agreement**: Open WebUI now operates under a custom license with Contributor License Agreement required by default—see https://docs.openwebui.com/license/ for details, ensuring sustainable open innovation for the community. +- 🔨 **CUDA Docker Images Updated to 12.8**: Upgraded CUDA image support for faster, more compatible model inference and futureproof GPU performance in your AI infrastructure. +- 🧱 **General Backend Refactoring for Reliability**: Continuous stability improvements streamline backend logic, reduce errors, and lay a stronger foundation for the next wave of feature releases—all under the hood for a more dependable WebUI. + ## [0.6.5] - 2025-04-14 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index eb54b48947..59285aa429 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,13 +2,13 @@ ## Our Pledge -As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation. +As members, contributors, and leaders of this community, we pledge to make participation in our project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We are committed to creating and maintaining an open, respectful, and professional environment where positive contributions and meaningful discussions can flourish. By participating in this project, you agree to uphold these values and align your behavior to the standards outlined in this Code of Conduct. ## Why These Standards Are Important -Open-source projects rely on a community of volunteers dedicating their time, expertise, and effort toward a shared goal. These projects are inherently collaborative but also fragile, as the success of the project depends on the goodwill, energy, and productivity of those involved. +Projects rely on a community of volunteers dedicating their time, expertise, and effort toward a shared goal. These projects are inherently collaborative but also fragile, as the success of the project depends on the goodwill, energy, and productivity of those involved. Maintaining a positive and respectful environment is essential to safeguarding the integrity of this project and protecting contributors' efforts. Behavior that disrupts this atmosphere—whether through hostility, entitlement, or unprofessional conduct—can severely harm the morale and productivity of the community. **Strict enforcement of these standards ensures a safe and supportive space for meaningful collaboration.** @@ -79,7 +79,7 @@ This approach ensures that disruptive behaviors are addressed swiftly and decisi ## Why Zero Tolerance Is Necessary -Open-source projects thrive on collaboration, goodwill, and mutual respect. Toxic behaviors—such as entitlement, hostility, or persistent negativity—threaten not just individual contributors but the health of the project as a whole. Allowing such behaviors to persist robs contributors of their time, energy, and enthusiasm for the work they do. +Projects thrive on collaboration, goodwill, and mutual respect. Toxic behaviors—such as entitlement, hostility, or persistent negativity—threaten not just individual contributors but the health of the project as a whole. Allowing such behaviors to persist robs contributors of their time, energy, and enthusiasm for the work they do. By enforcing a zero-tolerance policy, we ensure that the community remains a safe, welcoming space for all participants. These measures are not about harshness—they are about protecting contributors and fostering a productive environment where innovation can thrive. diff --git a/Caddyfile.localhost b/Caddyfile.localhost deleted file mode 100644 index 80728eedf6..0000000000 --- a/Caddyfile.localhost +++ /dev/null @@ -1,64 +0,0 @@ -# Run with -# caddy run --envfile ./example.env --config ./Caddyfile.localhost -# -# This is configured for -# - Automatic HTTPS (even for localhost) -# - Reverse Proxying to Ollama API Base URL (http://localhost:11434/api) -# - CORS -# - HTTP Basic Auth API Tokens (uncomment basicauth section) - - -# CORS Preflight (OPTIONS) + Request (GET, POST, PATCH, PUT, DELETE) -(cors-api) { - @match-cors-api-preflight method OPTIONS - handle @match-cors-api-preflight { - header { - Access-Control-Allow-Origin "{http.request.header.origin}" - Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" - Access-Control-Allow-Headers "Origin, Accept, Authorization, Content-Type, X-Requested-With" - Access-Control-Allow-Credentials "true" - Access-Control-Max-Age "3600" - defer - } - respond "" 204 - } - - @match-cors-api-request { - not { - header Origin "{http.request.scheme}://{http.request.host}" - } - header Origin "{http.request.header.origin}" - } - handle @match-cors-api-request { - header { - Access-Control-Allow-Origin "{http.request.header.origin}" - Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" - Access-Control-Allow-Headers "Origin, Accept, Authorization, Content-Type, X-Requested-With" - Access-Control-Allow-Credentials "true" - Access-Control-Max-Age "3600" - defer - } - } -} - -# replace localhost with example.com or whatever -localhost { - ## HTTP Basic Auth - ## (uncomment to enable) - # basicauth { - # # see .example.env for how to generate tokens - # {env.OLLAMA_API_ID} {env.OLLAMA_API_TOKEN_DIGEST} - # } - - handle /api/* { - # Comment to disable CORS - import cors-api - - reverse_proxy localhost:11434 - } - - # Same-Origin Static Web Server - file_server { - root ./build/ - } -} diff --git a/Dockerfile b/Dockerfile index 4a5411611f..5102afd28d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG USE_CUDA=false ARG USE_OLLAMA=false # Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default) -ARG USE_CUDA_VER=cu121 +ARG USE_CUDA_VER=cu128 # any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers # Leaderboard: https://huggingface.co/spaces/mteb/leaderboard # for better performance and multilangauge support use "intfloat/multilingual-e5-large" (~2.5GB) or "intfloat/multilingual-e5-base" (~1.5GB) diff --git a/LICENSE b/LICENSE index 89109d7516..3991050972 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2023-2025 Timothy Jaeryang Baek +Copyright (c) 2023-2025 Timothy Jaeryang Baek (Open WebUI) All rights reserved. Redistribution and use in source and binary forms, with or without @@ -15,6 +15,12 @@ modification, are permitted provided that the following conditions are met: contributors may be used to endorse or promote products derived from this software without specific prior written permission. +4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below. + +5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license. + +6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions. + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE diff --git a/README.md b/README.md index 54ad41503d..8445b5a392 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ ![GitHub language count](https://img.shields.io/github/languages/count/open-webui/open-webui) ![GitHub top language](https://img.shields.io/github/languages/top/open-webui/open-webui) ![GitHub last commit](https://img.shields.io/github/last-commit/open-webui/open-webui?color=red) -![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Follama-webui%2Follama-wbui&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false) [![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck) @@ -62,9 +61,26 @@ For more information, be sure to check out our [Open WebUI Documentation](https: Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview! -## 🔗 Also Check Out Open WebUI Community! +## Sponsors 🙌 -Don't forget to explore our sibling project, [Open WebUI Community](https://openwebui.com/), where you can discover, download, and explore customized Modelfiles. Open WebUI Community offers a wide range of exciting possibilities for enhancing your chat interactions with Open WebUI! 🚀 +#### Emerald + + + + + + +
+ + n8n + + + Does your interface have a backend yet?
Try n8n +
+ +--- + +We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you! ## How to Install 🚀 @@ -206,7 +222,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http ## License 📜 -This project is licensed under the [BSD-3-Clause License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄 +This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄 ## Support 💬 diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py index ff386957c4..967a49de8f 100644 --- a/backend/open_webui/__init__.py +++ b/backend/open_webui/__init__.py @@ -76,7 +76,7 @@ def serve( from open_webui.env import UVICORN_WORKERS # Import the workers setting uvicorn.run( - open_webui.main.app, + "open_webui.main:app", host=host, port=port, forwarded_allow_ips="*", diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 3b40977f27..38bd709f19 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -109,54 +109,7 @@ if os.path.exists(f"{DATA_DIR}/config.json"): DEFAULT_CONFIG = { "version": 0, - "ui": { - "default_locale": "", - "prompt_suggestions": [ - { - "title": [ - "Help me study", - "vocabulary for a college entrance exam", - ], - "content": "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.", - }, - { - "title": [ - "Give me ideas", - "for what to do with my kids' art", - ], - "content": "What are 5 creative things I could do with my kids' art? I don't want to throw them away, but it's also so much clutter.", - }, - { - "title": ["Tell me a fun fact", "about the Roman Empire"], - "content": "Tell me a random fun fact about the Roman Empire", - }, - { - "title": [ - "Show me a code snippet", - "of a website's sticky header", - ], - "content": "Show me a code snippet of a website's sticky header in CSS and JavaScript.", - }, - { - "title": [ - "Explain options trading", - "if I'm familiar with buying and selling stocks", - ], - "content": "Explain options trading in simple terms if I'm familiar with buying and selling stocks.", - }, - { - "title": ["Overcome procrastination", "give me tips"], - "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?", - }, - { - "title": [ - "Grammar check", - "rewrite it for better readability ", - ], - "content": 'Check the following sentence for grammar and clarity: "[sentence]". Rewrite it for better readability while maintaining its original meaning.', - }, - ], - }, + "ui": {}, } @@ -509,6 +462,19 @@ ENABLE_OAUTH_GROUP_MANAGEMENT = PersistentConfig( os.environ.get("ENABLE_OAUTH_GROUP_MANAGEMENT", "False").lower() == "true", ) +ENABLE_OAUTH_GROUP_CREATION = PersistentConfig( + "ENABLE_OAUTH_GROUP_CREATION", + "oauth.enable_group_creation", + os.environ.get("ENABLE_OAUTH_GROUP_CREATION", "False").lower() == "true", +) + + +OAUTH_BLOCKED_GROUPS = PersistentConfig( + "OAUTH_BLOCKED_GROUPS", + "oauth.blocked_groups", + os.environ.get("OAUTH_BLOCKED_GROUPS", "[]"), +) + OAUTH_ROLES_CLAIM = PersistentConfig( "OAUTH_ROLES_CLAIM", "oauth.roles_claim", @@ -539,6 +505,12 @@ OAUTH_ALLOWED_DOMAINS = PersistentConfig( ], ) +OAUTH_UPDATE_PICTURE_ON_LOGIN = PersistentConfig( + "OAUTH_UPDATE_PICTURE_ON_LOGIN", + "oauth.update_picture_on_login", + os.environ.get("OAUTH_UPDATE_PICTURE_ON_LOGIN", "False").lower() == "true", +) + def load_oauth_providers(): OAUTH_PROVIDERS.clear() @@ -748,9 +720,10 @@ S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None) S3_KEY_PREFIX = os.environ.get("S3_KEY_PREFIX", None) S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None) S3_USE_ACCELERATE_ENDPOINT = ( - os.environ.get("S3_USE_ACCELERATE_ENDPOINT", "False").lower() == "true" + os.environ.get("S3_USE_ACCELERATE_ENDPOINT", "false").lower() == "true" ) S3_ADDRESSING_STYLE = os.environ.get("S3_ADDRESSING_STYLE", None) +S3_ENABLE_TAGGING = os.getenv("S3_ENABLE_TAGGING", "false").lower() == "true" GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None) GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get( @@ -908,11 +881,19 @@ OPENAI_API_BASE_URL = "https://api.openai.com/v1" # TOOL_SERVERS #################################### +try: + tool_server_connections = json.loads( + os.environ.get("TOOL_SERVER_CONNECTIONS", "[]") + ) +except Exception as e: + log.exception(f"Error loading TOOL_SERVER_CONNECTIONS: {e}") + tool_server_connections = [] + TOOL_SERVER_CONNECTIONS = PersistentConfig( "TOOL_SERVER_CONNECTIONS", "tool_server.connections", - [], + tool_server_connections, ) #################################### @@ -952,10 +933,15 @@ DEFAULT_MODELS = PersistentConfig( "DEFAULT_MODELS", "ui.default_models", os.environ.get("DEFAULT_MODELS", None) ) -DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig( - "DEFAULT_PROMPT_SUGGESTIONS", - "ui.prompt_suggestions", - [ +try: + default_prompt_suggestions = json.loads( + os.environ.get("DEFAULT_PROMPT_SUGGESTIONS", "[]") + ) +except Exception as e: + log.exception(f"Error loading DEFAULT_PROMPT_SUGGESTIONS: {e}") + default_prompt_suggestions = [] +if default_prompt_suggestions == []: + default_prompt_suggestions = [ { "title": ["Help me study", "vocabulary for a college entrance exam"], "content": "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.", @@ -983,7 +969,12 @@ DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig( "title": ["Overcome procrastination", "give me tips"], "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?", }, - ], + ] + +DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig( + "DEFAULT_PROMPT_SUGGESTIONS", + "ui.prompt_suggestions", + default_prompt_suggestions, ) MODEL_ORDER_LIST = PersistentConfig( @@ -1062,6 +1053,14 @@ USER_PERMISSIONS_CHAT_EDIT = ( os.environ.get("USER_PERMISSIONS_CHAT_EDIT", "True").lower() == "true" ) +USER_PERMISSIONS_CHAT_SHARE = ( + os.environ.get("USER_PERMISSIONS_CHAT_SHARE", "True").lower() == "true" +) + +USER_PERMISSIONS_CHAT_EXPORT = ( + os.environ.get("USER_PERMISSIONS_CHAT_EXPORT", "True").lower() == "true" +) + USER_PERMISSIONS_CHAT_STT = ( os.environ.get("USER_PERMISSIONS_CHAT_STT", "True").lower() == "true" ) @@ -1107,6 +1106,10 @@ USER_PERMISSIONS_FEATURES_CODE_INTERPRETER = ( == "true" ) +USER_PERMISSIONS_FEATURES_NOTES = ( + os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true" +) + DEFAULT_USER_PERMISSIONS = { "workspace": { @@ -1126,6 +1129,8 @@ DEFAULT_USER_PERMISSIONS = { "file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD, "delete": USER_PERMISSIONS_CHAT_DELETE, "edit": USER_PERMISSIONS_CHAT_EDIT, + "share": USER_PERMISSIONS_CHAT_SHARE, + "export": USER_PERMISSIONS_CHAT_EXPORT, "stt": USER_PERMISSIONS_CHAT_STT, "tts": USER_PERMISSIONS_CHAT_TTS, "call": USER_PERMISSIONS_CHAT_CALL, @@ -1138,6 +1143,7 @@ DEFAULT_USER_PERMISSIONS = { "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, "image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, "code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, + "notes": USER_PERMISSIONS_FEATURES_NOTES, }, } @@ -1153,6 +1159,11 @@ ENABLE_CHANNELS = PersistentConfig( os.environ.get("ENABLE_CHANNELS", "False").lower() == "true", ) +ENABLE_NOTES = PersistentConfig( + "ENABLE_NOTES", + "notes.enable", + os.environ.get("ENABLE_NOTES", "True").lower() == "true", +) ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig( "ENABLE_EVALUATION_ARENA_MODELS", @@ -1203,6 +1214,18 @@ ENABLE_USER_WEBHOOKS = PersistentConfig( os.environ.get("ENABLE_USER_WEBHOOKS", "True").lower() == "true", ) +# FastAPI / AnyIO settings +THREAD_POOL_SIZE = os.getenv("THREAD_POOL_SIZE", None) + +if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str): + try: + THREAD_POOL_SIZE = int(THREAD_POOL_SIZE) + except ValueError: + log.warning( + f"THREAD_POOL_SIZE is not a valid integer: {THREAD_POOL_SIZE}. Defaulting to None." + ) + THREAD_POOL_SIZE = None + def validate_cors_origins(origins): for origin in origins: @@ -1229,7 +1252,9 @@ def validate_cors_origin(origin): # To test CORS_ALLOW_ORIGIN locally, you can set something like # CORS_ALLOW_ORIGIN=http://localhost:5173;http://localhost:8080 # in your .env file depending on your frontend port, 5173 in this case. -CORS_ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "*").split(";") +CORS_ALLOW_ORIGIN = os.environ.get( + "CORS_ALLOW_ORIGIN", "*;http://localhost:5173;http://localhost:8080" +).split(";") if "*" in CORS_ALLOW_ORIGIN: log.warning( @@ -1301,6 +1326,9 @@ Generate a concise, 3-5 word title with an emoji summarizing the chat history. - Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting. - Write the title in the chat's primary language; default to English if multilingual. - Prioritize accuracy over excessive creativity; keep it clear and simple. +- Your entire response must consist solely of the JSON object, without any introductory or concluding text. +- The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text. +- Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure. ### Output: JSON format: { "title": "your concise title here" } ### Examples: @@ -1643,7 +1671,8 @@ DEFAULT_CODE_INTERPRETER_PROMPT = """ 1. **Code Interpreter**: `` - You have access to a Python shell that runs directly in the user's browser, enabling fast execution of code for analysis, calculations, or problem-solving. Use it in this response. - The Python code you write can incorporate a wide array of libraries, handle data manipulation or visualization, perform API calls for web-related tasks, or tackle virtually any computational challenge. Use this flexibility to **think outside the box, craft elegant solutions, and harness Python's full potential**. - - To use it, **you must enclose your code within `` XML tags** and stop right away. If you don't, the code won't execute. Do NOT use triple backticks. + - To use it, **you must enclose your code within `` XML tags** and stop right away. If you don't, the code won't execute. + - When writing code in the code_interpreter XML tag, Do NOT use the triple backticks code block for markdown formatting, example: ```py # python code ``` will cause an error because it is markdown formatting, it is not python code. - When coding, **always aim to print meaningful outputs** (e.g., results, tables, summaries, or visuals) to better interpret and verify the findings. Avoid relying on implicit outputs; prioritize explicit and clear print statements so the results are effectively communicated to the user. - After obtaining the printed output, **always provide a concise analysis, interpretation, or next steps to help the user understand the findings or refine the outcome further.** - If the results are unclear, unexpected, or require validation, refine the code and execute it again as needed. Always aim to deliver meaningful insights from the results, iterating if necessary. @@ -1690,9 +1719,18 @@ MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db") MILVUS_DB = os.environ.get("MILVUS_DB", "default") MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None) +MILVUS_INDEX_TYPE = os.environ.get("MILVUS_INDEX_TYPE", "HNSW") +MILVUS_METRIC_TYPE = os.environ.get("MILVUS_METRIC_TYPE", "COSINE") +MILVUS_HNSW_M = int(os.environ.get("MILVUS_HNSW_M", "16")) +MILVUS_HNSW_EFCONSTRUCTION = int(os.environ.get("MILVUS_HNSW_EFCONSTRUCTION", "100")) +MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128")) + # Qdrant QDRANT_URI = os.environ.get("QDRANT_URI", None) QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None) +QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true" +QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "False").lower() == "true" +QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334")) # OpenSearch OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200") @@ -1724,6 +1762,14 @@ PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int( os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536") ) +# Pinecone +PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None) +PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None) +PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "open-webui-index") +PINECONE_DIMENSION = int(os.getenv("PINECONE_DIMENSION", 1536)) # or 3072, 1024, 768 +PINECONE_METRIC = os.getenv("PINECONE_METRIC", "cosine") +PINECONE_CLOUD = os.getenv("PINECONE_CLOUD", "aws") # or "gcp" or "azure" + #################################### # Information Retrieval (RAG) #################################### @@ -1760,6 +1806,18 @@ ONEDRIVE_CLIENT_ID = PersistentConfig( os.environ.get("ONEDRIVE_CLIENT_ID", ""), ) +ONEDRIVE_SHAREPOINT_URL = PersistentConfig( + "ONEDRIVE_SHAREPOINT_URL", + "onedrive.sharepoint_url", + os.environ.get("ONEDRIVE_SHAREPOINT_URL", ""), +) + +ONEDRIVE_SHAREPOINT_TENANT_ID = PersistentConfig( + "ONEDRIVE_SHAREPOINT_TENANT_ID", + "onedrive.sharepoint_tenant_id", + os.environ.get("ONEDRIVE_SHAREPOINT_TENANT_ID", ""), +) + # RAG Content Extraction CONTENT_EXTRACTION_ENGINE = PersistentConfig( "CONTENT_EXTRACTION_ENGINE", @@ -1779,6 +1837,18 @@ DOCLING_SERVER_URL = PersistentConfig( os.getenv("DOCLING_SERVER_URL", "http://docling:5001"), ) +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"), +) + DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig( "DOCUMENT_INTELLIGENCE_ENDPOINT", "rag.document_intelligence_endpoint", @@ -1895,6 +1965,12 @@ RAG_EMBEDDING_PREFIX_FIELD_NAME = os.environ.get( "RAG_EMBEDDING_PREFIX_FIELD_NAME", None ) +RAG_RERANKING_ENGINE = PersistentConfig( + "RAG_RERANKING_ENGINE", + "rag.reranking_engine", + os.environ.get("RAG_RERANKING_ENGINE", ""), +) + RAG_RERANKING_MODEL = PersistentConfig( "RAG_RERANKING_MODEL", "rag.reranking_model", @@ -1903,6 +1979,7 @@ RAG_RERANKING_MODEL = PersistentConfig( if RAG_RERANKING_MODEL.value != "": log.info(f"Reranking model set: {RAG_RERANKING_MODEL.value}") + RAG_RERANKING_MODEL_AUTO_UPDATE = ( not OFFLINE_MODE and os.environ.get("RAG_RERANKING_MODEL_AUTO_UPDATE", "True").lower() == "true" @@ -1912,6 +1989,18 @@ RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = ( os.environ.get("RAG_RERANKING_MODEL_TRUST_REMOTE_CODE", "True").lower() == "true" ) +RAG_EXTERNAL_RERANKER_URL = PersistentConfig( + "RAG_EXTERNAL_RERANKER_URL", + "rag.external_reranker_url", + os.environ.get("RAG_EXTERNAL_RERANKER_URL", ""), +) + +RAG_EXTERNAL_RERANKER_API_KEY = PersistentConfig( + "RAG_EXTERNAL_RERANKER_API_KEY", + "rag.external_reranker_api_key", + os.environ.get("RAG_EXTERNAL_RERANKER_API_KEY", ""), +) + RAG_TEXT_SPLITTER = PersistentConfig( "RAG_TEXT_SPLITTER", @@ -2087,6 +2176,24 @@ SEARXNG_QUERY_URL = PersistentConfig( os.getenv("SEARXNG_QUERY_URL", ""), ) +YACY_QUERY_URL = PersistentConfig( + "YACY_QUERY_URL", + "rag.web.search.yacy_query_url", + os.getenv("YACY_QUERY_URL", ""), +) + +YACY_USERNAME = PersistentConfig( + "YACY_USERNAME", + "rag.web.search.yacy_username", + os.getenv("YACY_USERNAME", ""), +) + +YACY_PASSWORD = PersistentConfig( + "YACY_PASSWORD", + "rag.web.search.yacy_password", + os.getenv("YACY_PASSWORD", ""), +) + GOOGLE_PSE_API_KEY = PersistentConfig( "GOOGLE_PSE_API_KEY", "rag.web.search.google_pse_api_key", @@ -2251,6 +2358,29 @@ FIRECRAWL_API_BASE_URL = PersistentConfig( os.environ.get("FIRECRAWL_API_BASE_URL", "https://api.firecrawl.dev"), ) +EXTERNAL_WEB_SEARCH_URL = PersistentConfig( + "EXTERNAL_WEB_SEARCH_URL", + "rag.web.search.external_web_search_url", + os.environ.get("EXTERNAL_WEB_SEARCH_URL", ""), +) + +EXTERNAL_WEB_SEARCH_API_KEY = PersistentConfig( + "EXTERNAL_WEB_SEARCH_API_KEY", + "rag.web.search.external_web_search_api_key", + os.environ.get("EXTERNAL_WEB_SEARCH_API_KEY", ""), +) + +EXTERNAL_WEB_LOADER_URL = PersistentConfig( + "EXTERNAL_WEB_LOADER_URL", + "rag.web.loader.external_web_loader_url", + os.environ.get("EXTERNAL_WEB_LOADER_URL", ""), +) + +EXTERNAL_WEB_LOADER_API_KEY = PersistentConfig( + "EXTERNAL_WEB_LOADER_API_KEY", + "rag.web.loader.external_web_loader_api_key", + os.environ.get("EXTERNAL_WEB_LOADER_API_KEY", ""), +) #################################### # Images @@ -2510,6 +2640,7 @@ WHISPER_VAD_FILTER = PersistentConfig( os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true", ) +WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "").lower() or None # Add Deepgram configuration DEEPGRAM_API_KEY = PersistentConfig( @@ -2561,6 +2692,18 @@ AUDIO_STT_AZURE_LOCALES = PersistentConfig( os.getenv("AUDIO_STT_AZURE_LOCALES", ""), ) +AUDIO_STT_AZURE_BASE_URL = PersistentConfig( + "AUDIO_STT_AZURE_BASE_URL", + "audio.stt.azure.base_url", + os.getenv("AUDIO_STT_AZURE_BASE_URL", ""), +) + +AUDIO_STT_AZURE_MAX_SPEAKERS = PersistentConfig( + "AUDIO_STT_AZURE_MAX_SPEAKERS", + "audio.stt.azure.max_speakers", + os.getenv("AUDIO_STT_AZURE_MAX_SPEAKERS", ""), +) + AUDIO_TTS_OPENAI_API_BASE_URL = PersistentConfig( "AUDIO_TTS_OPENAI_API_BASE_URL", "audio.tts.openai.api_base_url", @@ -2606,7 +2749,13 @@ AUDIO_TTS_SPLIT_ON = PersistentConfig( AUDIO_TTS_AZURE_SPEECH_REGION = PersistentConfig( "AUDIO_TTS_AZURE_SPEECH_REGION", "audio.tts.azure.speech_region", - os.getenv("AUDIO_TTS_AZURE_SPEECH_REGION", "eastus"), + os.getenv("AUDIO_TTS_AZURE_SPEECH_REGION", ""), +) + +AUDIO_TTS_AZURE_SPEECH_BASE_URL = PersistentConfig( + "AUDIO_TTS_AZURE_SPEECH_BASE_URL", + "audio.tts.azure.speech_base_url", + os.getenv("AUDIO_TTS_AZURE_SPEECH_BASE_URL", ""), ) AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT = PersistentConfig( diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index c9d71a4a03..59557349e3 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -354,6 +354,10 @@ BYPASS_MODEL_ACCESS_CONTROL = ( os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true" ) +WEBUI_AUTH_SIGNOUT_REDIRECT_URL = os.environ.get( + "WEBUI_AUTH_SIGNOUT_REDIRECT_URL", None +) + #################################### # WEBUI_SECRET_KEY #################################### @@ -409,6 +413,11 @@ else: except Exception: AIOHTTP_CLIENT_TIMEOUT = 300 + +AIOHTTP_CLIENT_SESSION_SSL = ( + os.environ.get("AIOHTTP_CLIENT_SESSION_SSL", "True").lower() == "true" +) + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get( "AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST", os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "10"), @@ -437,6 +446,56 @@ else: except Exception: AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10 + +AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = ( + os.environ.get("AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL", "True").lower() == "true" +) + + +#################################### +# SENTENCE TRANSFORMERS +#################################### + + +SENTENCE_TRANSFORMERS_BACKEND = os.environ.get("SENTENCE_TRANSFORMERS_BACKEND", "") +if SENTENCE_TRANSFORMERS_BACKEND == "": + SENTENCE_TRANSFORMERS_BACKEND = "torch" + + +SENTENCE_TRANSFORMERS_MODEL_KWARGS = os.environ.get( + "SENTENCE_TRANSFORMERS_MODEL_KWARGS", "" +) +if SENTENCE_TRANSFORMERS_MODEL_KWARGS == "": + SENTENCE_TRANSFORMERS_MODEL_KWARGS = None +else: + try: + SENTENCE_TRANSFORMERS_MODEL_KWARGS = json.loads( + SENTENCE_TRANSFORMERS_MODEL_KWARGS + ) + except Exception: + SENTENCE_TRANSFORMERS_MODEL_KWARGS = None + + +SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = os.environ.get( + "SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND", "" +) +if SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND == "": + SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = "torch" + + +SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = os.environ.get( + "SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS", "" +) +if SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS == "": + SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None +else: + try: + SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = json.loads( + SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS + ) + except Exception: + SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None + #################################### # OFFLINE_MODE #################################### @@ -446,6 +505,7 @@ OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true" if OFFLINE_MODE: os.environ["HF_HUB_OFFLINE"] = "1" + #################################### # AUDIT LOGGING #################################### @@ -467,6 +527,7 @@ AUDIT_EXCLUDED_PATHS = os.getenv("AUDIT_EXCLUDED_PATHS", "/chats,/chat,/folders" AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS] AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS] + #################################### # OPENTELEMETRY #################################### diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 56ea17fa18..e5fdace6d9 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -17,6 +17,7 @@ from sqlalchemy import text from typing import Optional from aiocache import cached import aiohttp +import anyio.to_thread import requests @@ -63,6 +64,7 @@ from open_webui.routers import ( auths, channels, chats, + notes, folders, configs, groups, @@ -100,11 +102,15 @@ from open_webui.config import ( # OpenAI ENABLE_OPENAI_API, ONEDRIVE_CLIENT_ID, + ONEDRIVE_SHAREPOINT_URL, + ONEDRIVE_SHAREPOINT_TENANT_ID, OPENAI_API_BASE_URLS, OPENAI_API_KEYS, OPENAI_API_CONFIGS, # Direct Connections ENABLE_DIRECT_CONNECTIONS, + # Thread pool size for FastAPI/AnyIO + THREAD_POOL_SIZE, # Tool Server Configs TOOL_SERVER_CONNECTIONS, # Code Execution @@ -151,6 +157,8 @@ from open_webui.config import ( AUDIO_STT_AZURE_API_KEY, AUDIO_STT_AZURE_REGION, AUDIO_STT_AZURE_LOCALES, + AUDIO_STT_AZURE_BASE_URL, + AUDIO_STT_AZURE_MAX_SPEAKERS, AUDIO_TTS_API_KEY, AUDIO_TTS_ENGINE, AUDIO_TTS_MODEL, @@ -159,6 +167,7 @@ from open_webui.config import ( AUDIO_TTS_SPLIT_ON, AUDIO_TTS_VOICE, AUDIO_TTS_AZURE_SPEECH_REGION, + AUDIO_TTS_AZURE_SPEECH_BASE_URL, AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT, PLAYWRIGHT_WS_URL, PLAYWRIGHT_TIMEOUT, @@ -167,6 +176,7 @@ from open_webui.config import ( WEB_LOADER_ENGINE, WHISPER_MODEL, WHISPER_VAD_FILTER, + WHISPER_LANGUAGE, DEEPGRAM_API_KEY, WHISPER_MODEL_AUTO_UPDATE, WHISPER_MODEL_DIR, @@ -178,7 +188,10 @@ from open_webui.config import ( RAG_EMBEDDING_MODEL, RAG_EMBEDDING_MODEL_AUTO_UPDATE, RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE, + RAG_RERANKING_ENGINE, RAG_RERANKING_MODEL, + RAG_EXTERNAL_RERANKER_URL, + RAG_EXTERNAL_RERANKER_API_KEY, RAG_RERANKING_MODEL_AUTO_UPDATE, RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, RAG_EMBEDDING_ENGINE, @@ -195,6 +208,8 @@ from open_webui.config import ( CONTENT_EXTRACTION_ENGINE, TIKA_SERVER_URL, DOCLING_SERVER_URL, + DOCLING_OCR_ENGINE, + DOCLING_OCR_LANG, DOCUMENT_INTELLIGENCE_ENDPOINT, DOCUMENT_INTELLIGENCE_KEY, MISTRAL_OCR_API_KEY, @@ -219,6 +234,9 @@ from open_webui.config import ( SERPAPI_API_KEY, SERPAPI_ENGINE, SEARXNG_QUERY_URL, + YACY_QUERY_URL, + YACY_USERNAME, + YACY_PASSWORD, SERPER_API_KEY, SERPLY_API_KEY, SERPSTACK_API_KEY, @@ -240,12 +258,18 @@ from open_webui.config import ( GOOGLE_DRIVE_CLIENT_ID, GOOGLE_DRIVE_API_KEY, ONEDRIVE_CLIENT_ID, + ONEDRIVE_SHAREPOINT_URL, + ONEDRIVE_SHAREPOINT_TENANT_ID, ENABLE_RAG_HYBRID_SEARCH, ENABLE_RAG_LOCAL_WEB_FETCH, ENABLE_WEB_LOADER_SSL_VERIFICATION, ENABLE_GOOGLE_DRIVE_INTEGRATION, ENABLE_ONEDRIVE_INTEGRATION, UPLOAD_DIR, + EXTERNAL_WEB_SEARCH_URL, + EXTERNAL_WEB_SEARCH_API_KEY, + EXTERNAL_WEB_LOADER_URL, + EXTERNAL_WEB_LOADER_API_KEY, # WebUI WEBUI_AUTH, WEBUI_NAME, @@ -260,6 +284,7 @@ from open_webui.config import ( ENABLE_API_KEY_ENDPOINT_RESTRICTIONS, API_KEY_ALLOWED_ENDPOINTS, ENABLE_CHANNELS, + ENABLE_NOTES, ENABLE_COMMUNITY_SHARING, ENABLE_MESSAGE_RATING, ENABLE_USER_WEBHOOKS, @@ -341,6 +366,7 @@ from open_webui.env import ( WEBUI_SESSION_COOKIE_SECURE, WEBUI_AUTH_TRUSTED_EMAIL_HEADER, WEBUI_AUTH_TRUSTED_NAME_HEADER, + WEBUI_AUTH_SIGNOUT_REDIRECT_URL, ENABLE_WEBSOCKET_SUPPORT, BYPASS_MODEL_ACCESS_CONTROL, RESET_CONFIG_ON_START, @@ -370,6 +396,7 @@ from open_webui.utils.auth import ( get_admin_user, get_verified_user, ) +from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.oauth import OAuthManager from open_webui.utils.security_headers import SecurityHeadersMiddleware @@ -416,7 +443,7 @@ print( ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚══╝╚══╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝ -v{VERSION} - building the best open-source AI user interface. +v{VERSION} - building the best AI user interface. {f"Commit: {WEBUI_BUILD_HASH}" if WEBUI_BUILD_HASH != "dev-build" else ""} https://github.com/open-webui/open-webui """ @@ -432,7 +459,17 @@ async def lifespan(app: FastAPI): if LICENSE_KEY: get_license_data(app, LICENSE_KEY) + # This should be blocking (sync) so functions are not deactivated on first /get_models calls + # when the first user lands on the / route. + log.info("Installing external dependencies of functions and tools...") + install_tool_and_function_dependencies() + + if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0: + limiter = anyio.to_thread.current_default_thread_limiter() + limiter.total_tokens = THREAD_POOL_SIZE + asyncio.create_task(periodic_usage_pool_cleanup()) + yield @@ -543,6 +580,7 @@ app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS +app.state.config.ENABLE_NOTES = ENABLE_NOTES app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS @@ -576,6 +614,7 @@ app.state.config.LDAP_CIPHERS = LDAP_CIPHERS app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER app.state.AUTH_TRUSTED_NAME_HEADER = WEBUI_AUTH_TRUSTED_NAME_HEADER +app.state.WEBUI_AUTH_SIGNOUT_REDIRECT_URL = WEBUI_AUTH_SIGNOUT_REDIRECT_URL app.state.EXTERNAL_PWA_MANIFEST_URL = EXTERNAL_PWA_MANIFEST_URL app.state.USER_COUNT = None @@ -604,6 +643,8 @@ app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ENABLE_WEB_LOADER_SSL_VERI app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL +app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE +app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY @@ -617,7 +658,12 @@ 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.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL +app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL +app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY + app.state.config.RAG_TEMPLATE = RAG_TEMPLATE app.state.config.RAG_OPENAI_API_BASE_URL = RAG_OPENAI_API_BASE_URL @@ -646,6 +692,9 @@ app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL +app.state.config.YACY_QUERY_URL = YACY_QUERY_URL +app.state.config.YACY_USERNAME = YACY_USERNAME +app.state.config.YACY_PASSWORD = YACY_PASSWORD app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID app.state.config.BRAVE_SEARCH_API_KEY = BRAVE_SEARCH_API_KEY @@ -668,6 +717,10 @@ app.state.config.EXA_API_KEY = EXA_API_KEY app.state.config.PERPLEXITY_API_KEY = PERPLEXITY_API_KEY app.state.config.SOUGOU_API_SID = SOUGOU_API_SID app.state.config.SOUGOU_API_SK = SOUGOU_API_SK +app.state.config.EXTERNAL_WEB_SEARCH_URL = EXTERNAL_WEB_SEARCH_URL +app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = EXTERNAL_WEB_SEARCH_API_KEY +app.state.config.EXTERNAL_WEB_LOADER_URL = EXTERNAL_WEB_LOADER_URL +app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL @@ -691,7 +744,10 @@ try: ) app.state.rf = get_rf( + app.state.config.RAG_RERANKING_ENGINE, app.state.config.RAG_RERANKING_MODEL, + app.state.config.RAG_EXTERNAL_RERANKER_URL, + app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, RAG_RERANKING_MODEL_AUTO_UPDATE, ) except Exception as e: @@ -796,6 +852,8 @@ app.state.config.DEEPGRAM_API_KEY = DEEPGRAM_API_KEY app.state.config.AUDIO_STT_AZURE_API_KEY = AUDIO_STT_AZURE_API_KEY app.state.config.AUDIO_STT_AZURE_REGION = AUDIO_STT_AZURE_REGION app.state.config.AUDIO_STT_AZURE_LOCALES = AUDIO_STT_AZURE_LOCALES +app.state.config.AUDIO_STT_AZURE_BASE_URL = AUDIO_STT_AZURE_BASE_URL +app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = AUDIO_STT_AZURE_MAX_SPEAKERS app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY @@ -807,6 +865,7 @@ app.state.config.TTS_SPLIT_ON = AUDIO_TTS_SPLIT_ON app.state.config.TTS_AZURE_SPEECH_REGION = AUDIO_TTS_AZURE_SPEECH_REGION +app.state.config.TTS_AZURE_SPEECH_BASE_URL = AUDIO_TTS_AZURE_SPEECH_BASE_URL app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT @@ -869,7 +928,8 @@ class RedirectMiddleware(BaseHTTPMiddleware): # Check for the specific watch path and the presence of 'v' parameter if path.endswith("/watch") and "v" in query_params: - video_id = query_params["v"][0] # Extract the first 'v' parameter + # Extract the first 'v' parameter + video_id = query_params["v"][0] encoded_video_id = urlencode({"youtube": video_id}) redirect_url = f"/?{encoded_video_id}" return RedirectResponse(url=redirect_url) @@ -955,6 +1015,8 @@ app.include_router(users.router, prefix="/api/v1/users", tags=["users"]) app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"]) app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"]) +app.include_router(notes.router, prefix="/api/v1/notes", tags=["notes"]) + app.include_router(models.router, prefix="/api/v1/models", tags=["models"]) app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"]) @@ -1283,6 +1345,7 @@ async def get_app_config(request: Request): { "enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS, "enable_channels": app.state.config.ENABLE_CHANNELS, + "enable_notes": app.state.config.ENABLE_NOTES, "enable_web_search": app.state.config.ENABLE_WEB_SEARCH, "enable_code_execution": app.state.config.ENABLE_CODE_EXECUTION, "enable_code_interpreter": app.state.config.ENABLE_CODE_INTERPRETER, @@ -1327,7 +1390,11 @@ async def get_app_config(request: Request): "client_id": GOOGLE_DRIVE_CLIENT_ID.value, "api_key": GOOGLE_DRIVE_API_KEY.value, }, - "onedrive": {"client_id": ONEDRIVE_CLIENT_ID.value}, + "onedrive": { + "client_id": ONEDRIVE_CLIENT_ID.value, + "sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value, + "sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value, + }, "license_metadata": app.state.LICENSE_METADATA, **( { @@ -1439,7 +1506,7 @@ async def get_manifest_json(): "start_url": "/", "display": "standalone", "background_color": "#343541", - "orientation": "natural", + "orientation": "any", "icons": [ { "src": "/static/logo.png", diff --git a/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py b/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py new file mode 100644 index 0000000000..8e983a2cff --- /dev/null +++ b/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py @@ -0,0 +1,33 @@ +"""Add note table + +Revision ID: 9f0c9cd09105 +Revises: 3781e22d8b01 +Create Date: 2025-05-03 03:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = "9f0c9cd09105" +down_revision = "3781e22d8b01" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "note", + sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True), + sa.Column("user_id", sa.Text(), nullable=True), + sa.Column("title", sa.Text(), nullable=True), + sa.Column("data", sa.JSON(), nullable=True), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("access_control", sa.JSON(), nullable=True), + sa.Column("created_at", sa.BigInteger(), nullable=True), + sa.Column("updated_at", sa.BigInteger(), nullable=True), + ) + + +def downgrade(): + op.drop_table("note") diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index a222d221c0..4b4f371976 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -436,7 +436,7 @@ class ChatTable: all_chats = query.all() - # result has to be destrctured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass. + # result has to be destructured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass. return [ ChatTitleIdResponse.model_validate( { diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py new file mode 100644 index 0000000000..114ccdc574 --- /dev/null +++ b/backend/open_webui/models/notes.py @@ -0,0 +1,135 @@ +import json +import time +import uuid +from typing import Optional + +from open_webui.internal.db import Base, get_db +from open_webui.utils.access_control import has_access +from open_webui.models.users import Users, UserResponse + + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON +from sqlalchemy import or_, func, select, and_, text +from sqlalchemy.sql import exists + +#################### +# Note DB Schema +#################### + + +class Note(Base): + __tablename__ = "note" + + id = Column(Text, primary_key=True) + user_id = Column(Text) + + title = Column(Text) + data = Column(JSON, nullable=True) + meta = Column(JSON, nullable=True) + + access_control = Column(JSON, nullable=True) + + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + +class NoteModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + + title: str + data: Optional[dict] = None + meta: Optional[dict] = None + + access_control: Optional[dict] = None + + created_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + + +#################### +# Forms +#################### + + +class NoteForm(BaseModel): + title: str + data: Optional[dict] = None + meta: Optional[dict] = None + access_control: Optional[dict] = None + + +class NoteUserResponse(NoteModel): + user: Optional[UserResponse] = None + + +class NoteTable: + def insert_new_note( + self, + form_data: NoteForm, + user_id: str, + ) -> Optional[NoteModel]: + with get_db() as db: + note = NoteModel( + **{ + "id": str(uuid.uuid4()), + "user_id": user_id, + **form_data.model_dump(), + "created_at": int(time.time_ns()), + "updated_at": int(time.time_ns()), + } + ) + + new_note = Note(**note.model_dump()) + + db.add(new_note) + db.commit() + return note + + def get_notes(self) -> list[NoteModel]: + with get_db() as db: + notes = db.query(Note).order_by(Note.updated_at.desc()).all() + return [NoteModel.model_validate(note) for note in notes] + + def get_notes_by_user_id( + self, user_id: str, permission: str = "write" + ) -> list[NoteModel]: + notes = self.get_notes() + return [ + note + for note in notes + if note.user_id == user_id + or has_access(user_id, permission, note.access_control) + ] + + def get_note_by_id(self, id: str) -> Optional[NoteModel]: + with get_db() as db: + note = db.query(Note).filter(Note.id == id).first() + return NoteModel.model_validate(note) if note else None + + def update_note_by_id(self, id: str, form_data: NoteForm) -> Optional[NoteModel]: + with get_db() as db: + note = db.query(Note).filter(Note.id == id).first() + if not note: + return None + + note.title = form_data.title + note.data = form_data.data + note.meta = form_data.meta + note.access_control = form_data.access_control + note.updated_at = int(time.time_ns()) + + db.commit() + return NoteModel.model_validate(note) if note else None + + def delete_note_by_id(self, id: str): + with get_db() as db: + db.query(Note).filter(Note.id == id).delete() + db.commit() + return True + + +Notes = NoteTable() diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 605299528d..3222aa27a6 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -10,6 +10,8 @@ from open_webui.models.groups import Groups from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, String, Text +from sqlalchemy import or_ + #################### # User DB Schema @@ -67,6 +69,11 @@ class UserModel(BaseModel): #################### +class UserListResponse(BaseModel): + users: list[UserModel] + total: int + + class UserResponse(BaseModel): id: str name: str @@ -160,11 +167,63 @@ class UsersTable: return None def get_users( - self, skip: Optional[int] = None, limit: Optional[int] = None - ) -> list[UserModel]: + self, + filter: Optional[dict] = None, + skip: Optional[int] = None, + limit: Optional[int] = None, + ) -> UserListResponse: with get_db() as db: + query = db.query(User) - query = db.query(User).order_by(User.created_at.desc()) + if filter: + query_key = filter.get("query") + if query_key: + query = query.filter( + or_( + User.name.ilike(f"%{query_key}%"), + User.email.ilike(f"%{query_key}%"), + ) + ) + + order_by = filter.get("order_by") + direction = filter.get("direction") + + if 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()) + else: + query = query.order_by(User.email.desc()) + + elif order_by == "created_at": + if direction == "asc": + query = query.order_by(User.created_at.asc()) + else: + query = query.order_by(User.created_at.desc()) + + elif order_by == "last_active_at": + if direction == "asc": + query = query.order_by(User.last_active_at.asc()) + else: + query = query.order_by(User.last_active_at.desc()) + + elif order_by == "updated_at": + if direction == "asc": + query = query.order_by(User.updated_at.asc()) + else: + query = query.order_by(User.updated_at.desc()) + elif order_by == "role": + if direction == "asc": + query = query.order_by(User.role.asc()) + else: + query = query.order_by(User.role.desc()) + + else: + query = query.order_by(User.created_at.desc()) if skip: query = query.offset(skip) @@ -172,8 +231,10 @@ class UsersTable: query = query.limit(limit) users = query.all() - - return [UserModel.model_validate(user) for user in users] + return { + "users": [UserModel.model_validate(user) for user in users], + "total": db.query(User).count(), + } def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]: with get_db() as db: @@ -330,5 +391,13 @@ class UsersTable: users = db.query(User).filter(User.id.in_(user_ids)).all() return [user.id for user in users] + def get_super_admin_user(self) -> Optional[UserModel]: + with get_db() as db: + user = db.query(User).filter_by(role="admin").first() + if user: + return UserModel.model_validate(user) + else: + return None + Users = UsersTable() diff --git a/backend/open_webui/retrieval/loaders/external.py b/backend/open_webui/retrieval/loaders/external.py new file mode 100644 index 0000000000..642cfd3a5e --- /dev/null +++ b/backend/open_webui/retrieval/loaders/external.py @@ -0,0 +1,53 @@ +import requests +import logging +from typing import Iterator, List, Union + +from langchain_core.document_loaders import BaseLoader +from langchain_core.documents import Document +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class ExternalLoader(BaseLoader): + def __init__( + self, + web_paths: Union[str, List[str]], + external_url: str, + external_api_key: str, + continue_on_failure: bool = True, + **kwargs, + ) -> None: + self.external_url = external_url + self.external_api_key = external_api_key + self.urls = web_paths if isinstance(web_paths, list) else [web_paths] + self.continue_on_failure = continue_on_failure + + def lazy_load(self) -> Iterator[Document]: + batch_size = 20 + for i in range(0, len(self.urls), batch_size): + urls = self.urls[i : i + batch_size] + try: + response = requests.post( + self.external_url, + headers={ + "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", + "Authorization": f"Bearer {self.external_api_key}", + }, + json={ + "urls": urls, + }, + ) + response.raise_for_status() + results = response.json() + for result in results: + yield Document( + page_content=result.get("page_content", ""), + metadata=result.get("metadata", {}), + ) + except Exception as e: + if self.continue_on_failure: + log.error(f"Error extracting content from batch {urls}: {e}") + else: + raise e diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index 24944bd8a4..8e7b5a3da0 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -85,11 +85,13 @@ known_source_ext = [ class TikaLoader: - def __init__(self, url, file_path, mime_type=None): + def __init__(self, url, file_path, mime_type=None, extract_images=None): self.url = url self.file_path = file_path self.mime_type = mime_type + self.extract_images = extract_images + def load(self) -> list[Document]: with open(self.file_path, "rb") as f: data = f.read() @@ -99,6 +101,9 @@ class TikaLoader: else: headers = {} + if self.extract_images == True: + headers["X-Tika-PDFextractInlineImages"] = "true" + endpoint = self.url if not endpoint.endswith("/"): endpoint += "/" @@ -121,10 +126,14 @@ class TikaLoader: class DoclingLoader: - def __init__(self, url, file_path=None, mime_type=None): + def __init__( + self, url, file_path=None, mime_type=None, ocr_engine=None, ocr_lang=None + ): self.url = url.rstrip("/") self.file_path = file_path self.mime_type = mime_type + self.ocr_engine = ocr_engine + self.ocr_lang = ocr_lang def load(self) -> list[Document]: with open(self.file_path, "rb") as f: @@ -141,6 +150,12 @@ class DoclingLoader: "table_mode": "accurate", } + if self.ocr_engine and self.ocr_lang: + params["ocr_engine"] = self.ocr_engine + params["ocr_lang"] = [ + lang.strip() for lang in self.ocr_lang.split(",") if lang.strip() + ] + endpoint = f"{self.url}/v1alpha/convert/file" r = requests.post(endpoint, files=files, data=params) @@ -200,6 +215,7 @@ class Loader: url=self.kwargs.get("TIKA_SERVER_URL"), file_path=file_path, mime_type=file_content_type, + extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"), ) elif self.engine == "docling" and self.kwargs.get("DOCLING_SERVER_URL"): if self._is_text_file(file_ext, file_content_type): @@ -209,6 +225,8 @@ class Loader: url=self.kwargs.get("DOCLING_SERVER_URL"), file_path=file_path, mime_type=file_content_type, + ocr_engine=self.kwargs.get("DOCLING_OCR_ENGINE"), + ocr_lang=self.kwargs.get("DOCLING_OCR_LANG"), ) elif ( self.engine == "document_intelligence" diff --git a/backend/open_webui/retrieval/loaders/youtube.py b/backend/open_webui/retrieval/loaders/youtube.py index f59dd7df55..d908cc8cb5 100644 --- a/backend/open_webui/retrieval/loaders/youtube.py +++ b/backend/open_webui/retrieval/loaders/youtube.py @@ -62,12 +62,17 @@ class YoutubeLoader: _video_id = _parse_video_id(video_id) self.video_id = _video_id if _video_id is not None else video_id self._metadata = {"source": video_id} - self.language = language self.proxy_url = proxy_url + + # Ensure language is a list if isinstance(language, str): self.language = [language] else: - self.language = language + self.language = list(language) + + # Add English as fallback if not already in the list + if "en" not in self.language: + self.language.append("en") def load(self) -> List[Document]: """Load YouTube transcripts into `Document` objects.""" @@ -101,17 +106,31 @@ class YoutubeLoader: log.exception("Loading YouTube transcript failed") return [] - try: - transcript = transcript_list.find_transcript(self.language) - except NoTranscriptFound: - transcript = transcript_list.find_transcript(["en"]) + # Try each language in order of priority + for lang in self.language: + try: + transcript = transcript_list.find_transcript([lang]) + log.debug(f"Found transcript for language '{lang}'") + transcript_pieces: List[Dict[str, Any]] = transcript.fetch() + transcript_text = " ".join( + map( + lambda transcript_piece: transcript_piece.text.strip(" "), + transcript_pieces, + ) + ) + return [Document(page_content=transcript_text, metadata=self._metadata)] + except NoTranscriptFound: + log.debug(f"No transcript found for language '{lang}'") + continue + except Exception as e: + log.info(f"Error finding transcript for language '{lang}'") + raise e - transcript_pieces: List[Dict[str, Any]] = transcript.fetch() - - transcript = " ".join( - map( - lambda transcript_piece: transcript_piece.text.strip(" "), - transcript_pieces, - ) + # If we get here, all languages failed + languages_tried = ", ".join(self.language) + log.warning( + f"No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed." + ) + raise NoTranscriptFound( + f"No transcript found for any supported language. Verify if the video has transcripts, add more languages if needed." ) - return [Document(page_content=transcript, metadata=self._metadata)] diff --git a/backend/open_webui/retrieval/models/external.py b/backend/open_webui/retrieval/models/external.py new file mode 100644 index 0000000000..187d66e384 --- /dev/null +++ b/backend/open_webui/retrieval/models/external.py @@ -0,0 +1,58 @@ +import logging +import requests +from typing import Optional, List, Tuple + +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class ExternalReranker: + def __init__( + self, + api_key: str, + url: str = "http://localhost:8080/v1/rerank", + model: str = "reranker", + ): + self.api_key = api_key + self.url = url + self.model = model + + def predict(self, sentences: List[Tuple[str, str]]) -> Optional[List[float]]: + query = sentences[0][0] + docs = [i[1] for i in sentences] + + payload = { + "model": self.model, + "query": query, + "documents": docs, + "top_n": len(docs), + } + + try: + log.info(f"ExternalReranker:predict:model {self.model}") + log.info(f"ExternalReranker:predict:query {query}") + + r = requests.post( + f"{self.url}", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + }, + json=payload, + ) + + r.raise_for_status() + data = r.json() + + if "results" in data: + sorted_results = sorted(data["results"], key=lambda x: x["index"]) + return [result["relevance_score"] for result in sorted_results] + else: + log.error("No results found in external reranking response") + return None + + except Exception as e: + log.exception(f"Error in external reranking: {e}") + return None diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 2b23cad17f..2df6a0ab51 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -207,7 +207,7 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict: for distance, document, metadata in zip(distances, documents, metadatas): if isinstance(document, str): - doc_hash = hashlib.md5( + doc_hash = hashlib.sha256( document.encode() ).hexdigest() # Compute a hash for uniqueness @@ -260,23 +260,47 @@ def query_collection( k: int, ) -> dict: results = [] - for query in queries: - log.debug(f"query_collection:query {query}") - query_embedding = embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX) - for collection_name in collection_names: + error = False + + def process_query_collection(collection_name, query_embedding): + try: if collection_name: - try: - result = query_doc( - collection_name=collection_name, - k=k, - query_embedding=query_embedding, - ) - if result is not None: - results.append(result.model_dump()) - except Exception as e: - log.exception(f"Error when querying the collection: {e}") - else: - pass + result = query_doc( + collection_name=collection_name, + k=k, + query_embedding=query_embedding, + ) + if result is not None: + return result.model_dump(), None + return None, None + except Exception as e: + log.exception(f"Error when querying the collection: {e}") + return None, e + + # Generate all query embeddings (in one call) + query_embeddings = embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX) + log.debug( + f"query_collection: processing {len(queries)} queries across {len(collection_names)} collections" + ) + + with ThreadPoolExecutor() as executor: + future_results = [] + for query_embedding in query_embeddings: + for collection_name in collection_names: + result = executor.submit( + process_query_collection, collection_name, query_embedding + ) + future_results.append(result) + task_results = [future.result() for future in future_results] + + for result, err in task_results: + if err is not None: + error = True + elif result is not None: + results.append(result) + + if error and not results: + log.warning("All collection queries failed. No results returned.") return merge_and_sort_query_results(results, k=k) @@ -794,7 +818,9 @@ class RerankCompressor(BaseDocumentCompressor): ) scores = util.cos_sim(query_embedding, document_embedding)[0] - docs_with_scores = list(zip(documents, scores.tolist())) + docs_with_scores = list( + zip(documents, scores.tolist() if not isinstance(scores, list) else scores) + ) if self.r_score: docs_with_scores = [ (d, s) for d, s in docs_with_scores if s >= self.r_score diff --git a/backend/open_webui/retrieval/vector/connector.py b/backend/open_webui/retrieval/vector/connector.py index ac8884c043..198e6f1761 100644 --- a/backend/open_webui/retrieval/vector/connector.py +++ b/backend/open_webui/retrieval/vector/connector.py @@ -20,6 +20,10 @@ elif VECTOR_DB == "elasticsearch": from open_webui.retrieval.vector.dbs.elasticsearch import ElasticsearchClient VECTOR_DB_CLIENT = ElasticsearchClient() +elif VECTOR_DB == "pinecone": + from open_webui.retrieval.vector.dbs.pinecone import PineconeClient + + VECTOR_DB_CLIENT = PineconeClient() else: from open_webui.retrieval.vector.dbs.chroma import ChromaClient diff --git a/backend/open_webui/retrieval/vector/dbs/chroma.py b/backend/open_webui/retrieval/vector/dbs/chroma.py index a6b97df3e9..f9adc9c95f 100755 --- a/backend/open_webui/retrieval/vector/dbs/chroma.py +++ b/backend/open_webui/retrieval/vector/dbs/chroma.py @@ -5,7 +5,12 @@ from chromadb.utils.batch_utils import create_batches from typing import Optional -from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) from open_webui.config import ( CHROMA_DATA_PATH, CHROMA_HTTP_HOST, @@ -23,7 +28,7 @@ log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) -class ChromaClient: +class ChromaClient(VectorDBBase): def __init__(self): settings_dict = { "allow_reset": True, diff --git a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py index c896284946..18a915e381 100644 --- a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py +++ b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py @@ -2,7 +2,12 @@ from elasticsearch import Elasticsearch, BadRequestError from typing import Optional import ssl from elasticsearch.helpers import bulk, scan -from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) from open_webui.config import ( ELASTICSEARCH_URL, ELASTICSEARCH_CA_CERTS, @@ -15,7 +20,7 @@ from open_webui.config import ( ) -class ElasticsearchClient: +class ElasticsearchClient(VectorDBBase): """ Important: in order to reduce the number of indexes and since the embedding vector length is fixed, we avoid creating diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index 26b4dd5ed2..a4bad13d00 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -3,12 +3,21 @@ from pymilvus import FieldSchema, DataType import json import logging from typing import Optional - -from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) from open_webui.config import ( MILVUS_URI, MILVUS_DB, MILVUS_TOKEN, + MILVUS_INDEX_TYPE, + MILVUS_METRIC_TYPE, + MILVUS_HNSW_M, + MILVUS_HNSW_EFCONSTRUCTION, + MILVUS_IVF_FLAT_NLIST, ) from open_webui.env import SRC_LOG_LEVELS @@ -16,7 +25,7 @@ log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) -class MilvusClient: +class MilvusClient(VectorDBBase): def __init__(self): self.collection_prefix = "open_webui" if MILVUS_TOKEN is None: @@ -28,7 +37,6 @@ class MilvusClient: ids = [] documents = [] metadatas = [] - for match in result: _ids = [] _documents = [] @@ -37,11 +45,9 @@ class MilvusClient: _ids.append(item.get("id")) _documents.append(item.get("data", {}).get("text")) _metadatas.append(item.get("metadata")) - ids.append(_ids) documents.append(_documents) metadatas.append(_metadatas) - return GetResult( **{ "ids": ids, @@ -55,13 +61,11 @@ class MilvusClient: distances = [] documents = [] metadatas = [] - for match in result: _ids = [] _distances = [] _documents = [] _metadatas = [] - for item in match: _ids.append(item.get("id")) # normalize milvus score from [-1, 1] to [0, 1] range @@ -70,12 +74,10 @@ class MilvusClient: _distances.append(_dist) _documents.append(item.get("entity", {}).get("data", {}).get("text")) _metadatas.append(item.get("entity", {}).get("metadata")) - ids.append(_ids) distances.append(_distances) documents.append(_documents) metadatas.append(_metadatas) - return SearchResult( **{ "ids": ids, @@ -108,11 +110,39 @@ class MilvusClient: ) index_params = self.client.prepare_index_params() + + # Use configurations from config.py + index_type = MILVUS_INDEX_TYPE.upper() + metric_type = MILVUS_METRIC_TYPE.upper() + + log.info(f"Using Milvus index type: {index_type}, metric type: {metric_type}") + + index_creation_params = {} + if index_type == "HNSW": + index_creation_params = { + "M": MILVUS_HNSW_M, + "efConstruction": MILVUS_HNSW_EFCONSTRUCTION, + } + log.info(f"HNSW params: {index_creation_params}") + elif index_type == "IVF_FLAT": + index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST} + log.info(f"IVF_FLAT params: {index_creation_params}") + elif index_type in ["FLAT", "AUTOINDEX"]: + log.info(f"Using {index_type} index with no specific build-time params.") + else: + log.warning( + f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. " + f"Supported types: HNSW, IVF_FLAT, FLAT, AUTOINDEX. " + f"Milvus will use its default for the collection if this type is not directly supported for index creation." + ) + # For unsupported types, pass the type directly to Milvus; it might handle it or use a default. + # If Milvus errors out, the user needs to correct the MILVUS_INDEX_TYPE env var. + index_params.add_index( field_name="vector", - index_type="HNSW", - metric_type="COSINE", - params={"M": 16, "efConstruction": 100}, + index_type=index_type, + metric_type=metric_type, + params=index_creation_params, ) self.client.create_collection( @@ -120,6 +150,9 @@ class MilvusClient: schema=schema, index_params=index_params, ) + log.info( + f"Successfully created collection '{self.collection_prefix}_{collection_name}' with index type '{index_type}' and metric '{metric_type}'." + ) def has_collection(self, collection_name: str) -> bool: # Check if the collection exists based on the collection name. @@ -140,84 +173,113 @@ class MilvusClient: ) -> Optional[SearchResult]: # Search for the nearest neighbor items based on the vectors and return 'limit' number of results. collection_name = collection_name.replace("-", "_") + # For some index types like IVF_FLAT, search params like nprobe can be set. + # Example: search_params = {"nprobe": 10} if using IVF_FLAT + # For simplicity, not adding configurable search_params here, but could be extended. result = self.client.search( collection_name=f"{self.collection_prefix}_{collection_name}", data=vectors, limit=limit, output_fields=["data", "metadata"], + # search_params=search_params # Potentially add later if needed ) - return self._result_to_search_result(result) def query(self, collection_name: str, filter: dict, limit: Optional[int] = None): # Construct the filter string for querying collection_name = collection_name.replace("-", "_") if not self.has_collection(collection_name): + log.warning( + f"Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}" + ) return None - filter_string = " && ".join( [ f'metadata["{key}"] == {json.dumps(value)}' for key, value in filter.items() ] ) - max_limit = 16383 # The maximum number of records per request all_results = [] - if limit is None: - limit = float("inf") # Use infinity as a placeholder for no limit + # Milvus default limit for query if not specified is 16384, but docs mention iteration. + # Let's set a practical high number if "all" is intended, or handle true pagination. + # For now, if limit is None, we'll fetch in batches up to a very large number. + # This part could be refined based on expected use cases for "get all". + # For this function signature, None implies "as many as possible" up to Milvus limits. + limit = ( + 16384 * 10 + ) # A large number to signify fetching many, will be capped by actual data or max_limit per call. + log.info( + f"Limit not specified for query, fetching up to {limit} results in batches." + ) # Initialize offset and remaining to handle pagination offset = 0 remaining = limit try: + log.info( + f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}" + ) # Loop until there are no more items to fetch or the desired limit is reached while remaining > 0: - log.info(f"remaining: {remaining}") current_fetch = min( - max_limit, remaining - ) # Determine how many items to fetch in this iteration + max_limit, remaining if isinstance(remaining, int) else max_limit + ) + log.debug( + f"Querying with offset: {offset}, current_fetch: {current_fetch}" + ) results = self.client.query( collection_name=f"{self.collection_prefix}_{collection_name}", filter=filter_string, - output_fields=["*"], + output_fields=[ + "id", + "data", + "metadata", + ], # Explicitly list needed fields. Vector not usually needed in query. limit=current_fetch, offset=offset, ) if not results: + log.debug("No more results from query.") break all_results.extend(results) results_count = len(results) - remaining -= ( - results_count # Decrease remaining by the number of items fetched - ) + log.debug(f"Fetched {results_count} results in this batch.") + + if isinstance(remaining, int): + remaining -= results_count + offset += results_count - # Break the loop if the results returned are less than the requested fetch count + # Break the loop if the results returned are less than the requested fetch count (means end of data) if results_count < current_fetch: + log.debug( + "Fetched less than requested, assuming end of results for this query." + ) break - log.debug(all_results) + log.info(f"Total results from query: {len(all_results)}") return self._result_to_get_result([all_results]) except Exception as e: log.exception( - f"Error querying collection {collection_name} with limit {limit}: {e}" + f"Error querying collection {self.collection_prefix}_{collection_name} with filter '{filter_string}' and limit {limit}: {e}" ) return None def get(self, collection_name: str) -> Optional[GetResult]: - # Get all the items in the collection. + # Get all the items in the collection. This can be very resource-intensive for large collections. collection_name = collection_name.replace("-", "_") - result = self.client.query( - collection_name=f"{self.collection_prefix}_{collection_name}", - filter='id != ""', + log.warning( + f"Fetching ALL items from collection '{self.collection_prefix}_{collection_name}'. This might be slow for large collections." ) - return self._result_to_get_result([result]) + # Using query with a trivial filter to get all items. + # This will use the paginated query logic. + return self.query(collection_name=collection_name, filter={}, limit=None) def insert(self, collection_name: str, items: list[VectorItem]): # Insert the items into the collection, if the collection does not exist, it will be created. @@ -225,10 +287,23 @@ class MilvusClient: if not self.client.has_collection( collection_name=f"{self.collection_prefix}_{collection_name}" ): + log.info( + f"Collection {self.collection_prefix}_{collection_name} does not exist. Creating now." + ) + if not items: + log.error( + f"Cannot create collection {self.collection_prefix}_{collection_name} without items to determine dimension." + ) + raise ValueError( + "Cannot create Milvus collection without items to determine vector dimension." + ) self._create_collection( collection_name=collection_name, dimension=len(items[0]["vector"]) ) + log.info( + f"Inserting {len(items)} items into collection {self.collection_prefix}_{collection_name}." + ) return self.client.insert( collection_name=f"{self.collection_prefix}_{collection_name}", data=[ @@ -248,10 +323,23 @@ class MilvusClient: if not self.client.has_collection( collection_name=f"{self.collection_prefix}_{collection_name}" ): + log.info( + f"Collection {self.collection_prefix}_{collection_name} does not exist for upsert. Creating now." + ) + if not items: + log.error( + f"Cannot create collection {self.collection_prefix}_{collection_name} for upsert without items to determine dimension." + ) + raise ValueError( + "Cannot create Milvus collection for upsert without items to determine vector dimension." + ) self._create_collection( collection_name=collection_name, dimension=len(items[0]["vector"]) ) + log.info( + f"Upserting {len(items)} items into collection {self.collection_prefix}_{collection_name}." + ) return self.client.upsert( collection_name=f"{self.collection_prefix}_{collection_name}", data=[ @@ -271,30 +359,55 @@ class MilvusClient: ids: Optional[list[str]] = None, filter: Optional[dict] = None, ): - # Delete the items from the collection based on the ids. + # Delete the items from the collection based on the ids or filter. collection_name = collection_name.replace("-", "_") + if not self.has_collection(collection_name): + log.warning( + f"Delete attempted on non-existent collection: {self.collection_prefix}_{collection_name}" + ) + return None + if ids: + log.info( + f"Deleting items by IDs from {self.collection_prefix}_{collection_name}. IDs: {ids}" + ) return self.client.delete( collection_name=f"{self.collection_prefix}_{collection_name}", ids=ids, ) elif filter: - # Convert the filter dictionary to a string using JSON_CONTAINS. filter_string = " && ".join( [ f'metadata["{key}"] == {json.dumps(value)}' for key, value in filter.items() ] ) - + log.info( + f"Deleting items by filter from {self.collection_prefix}_{collection_name}. Filter: {filter_string}" + ) return self.client.delete( collection_name=f"{self.collection_prefix}_{collection_name}", filter=filter_string, ) + else: + log.warning( + f"Delete operation on {self.collection_prefix}_{collection_name} called without IDs or filter. No action taken." + ) + return None def reset(self): - # Resets the database. This will delete all collections and item entries. + # Resets the database. This will delete all collections and item entries that match the prefix. + log.warning( + f"Resetting Milvus: Deleting all collections with prefix '{self.collection_prefix}'." + ) collection_names = self.client.list_collections() - for collection_name in collection_names: - if collection_name.startswith(self.collection_prefix): - self.client.drop_collection(collection_name=collection_name) + deleted_collections = [] + for collection_name_full in collection_names: + if collection_name_full.startswith(self.collection_prefix): + try: + self.client.drop_collection(collection_name=collection_name_full) + deleted_collections.append(collection_name_full) + log.info(f"Deleted collection: {collection_name_full}") + except Exception as e: + log.error(f"Error deleting collection {collection_name_full}: {e}") + log.info(f"Milvus reset complete. Deleted collections: {deleted_collections}") diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py index 432bcef412..60ef2d906c 100644 --- a/backend/open_webui/retrieval/vector/dbs/opensearch.py +++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py @@ -2,7 +2,12 @@ from opensearchpy import OpenSearch from opensearchpy.helpers import bulk from typing import Optional -from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) from open_webui.config import ( OPENSEARCH_URI, OPENSEARCH_SSL, @@ -12,7 +17,7 @@ from open_webui.config import ( ) -class OpenSearchClient: +class OpenSearchClient(VectorDBBase): def __init__(self): self.index_prefix = "open_webui" self.client = OpenSearch( diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index c38dbb0367..b6cb2a4e25 100644 --- a/backend/open_webui/retrieval/vector/dbs/pgvector.py +++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py @@ -22,7 +22,12 @@ from pgvector.sqlalchemy import Vector from sqlalchemy.ext.mutable import MutableDict from sqlalchemy.exc import NoSuchTableError -from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH from open_webui.env import SRC_LOG_LEVELS @@ -44,7 +49,7 @@ class DocumentChunk(Base): vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True) -class PgvectorClient: +class PgvectorClient(VectorDBBase): def __init__(self) -> None: # if no pgvector uri, use the existing database connection @@ -136,9 +141,8 @@ class PgvectorClient: # Pad the vector with zeros vector += [0.0] * (VECTOR_LENGTH - current_length) elif current_length > VECTOR_LENGTH: - raise Exception( - f"Vector length {current_length} not supported. Max length must be <= {VECTOR_LENGTH}" - ) + # Truncate the vector to VECTOR_LENGTH + vector = vector[:VECTOR_LENGTH] return vector def insert(self, collection_name: str, items: List[VectorItem]) -> None: diff --git a/backend/open_webui/retrieval/vector/dbs/pinecone.py b/backend/open_webui/retrieval/vector/dbs/pinecone.py new file mode 100644 index 0000000000..c921089b6d --- /dev/null +++ b/backend/open_webui/retrieval/vector/dbs/pinecone.py @@ -0,0 +1,533 @@ +from typing import Optional, List, Dict, Any, Union +import logging +import time # for measuring elapsed time +from pinecone import ServerlessSpec + +import asyncio # for async upserts +import functools # for partial binding in async tasks + +import concurrent.futures # for parallel batch upserts +from pinecone.grpc import PineconeGRPC # use gRPC client for faster upserts + +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) +from open_webui.config import ( + PINECONE_API_KEY, + PINECONE_ENVIRONMENT, + PINECONE_INDEX_NAME, + PINECONE_DIMENSION, + PINECONE_METRIC, + PINECONE_CLOUD, +) +from open_webui.env import SRC_LOG_LEVELS + +NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system +BATCH_SIZE = 100 # Recommended batch size for Pinecone operations + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class PineconeClient(VectorDBBase): + def __init__(self): + self.collection_prefix = "open-webui" + + # Validate required configuration + self._validate_config() + + # Store configuration values + self.api_key = PINECONE_API_KEY + self.environment = PINECONE_ENVIRONMENT + self.index_name = PINECONE_INDEX_NAME + self.dimension = PINECONE_DIMENSION + self.metric = PINECONE_METRIC + self.cloud = PINECONE_CLOUD + + # Initialize Pinecone gRPC client for improved performance + self.client = PineconeGRPC( + api_key=self.api_key, environment=self.environment, cloud=self.cloud + ) + + # Persistent executor for batch operations + self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=5) + + # Create index if it doesn't exist + self._initialize_index() + + def _validate_config(self) -> None: + """Validate that all required configuration variables are set.""" + missing_vars = [] + if not PINECONE_API_KEY: + missing_vars.append("PINECONE_API_KEY") + if not PINECONE_ENVIRONMENT: + missing_vars.append("PINECONE_ENVIRONMENT") + if not PINECONE_INDEX_NAME: + missing_vars.append("PINECONE_INDEX_NAME") + if not PINECONE_DIMENSION: + missing_vars.append("PINECONE_DIMENSION") + if not PINECONE_CLOUD: + missing_vars.append("PINECONE_CLOUD") + + if missing_vars: + raise ValueError( + f"Required configuration missing: {', '.join(missing_vars)}" + ) + + def _initialize_index(self) -> None: + """Initialize the Pinecone index.""" + try: + # Check if index exists + if self.index_name not in self.client.list_indexes().names(): + log.info(f"Creating Pinecone index '{self.index_name}'...") + self.client.create_index( + name=self.index_name, + dimension=self.dimension, + metric=self.metric, + spec=ServerlessSpec(cloud=self.cloud, region=self.environment), + ) + log.info(f"Successfully created Pinecone index '{self.index_name}'") + else: + log.info(f"Using existing Pinecone index '{self.index_name}'") + + # Connect to the index + self.index = self.client.Index(self.index_name) + + except Exception as e: + log.error(f"Failed to initialize Pinecone index: {e}") + raise RuntimeError(f"Failed to initialize Pinecone index: {e}") + + def _create_points( + self, items: List[VectorItem], collection_name_with_prefix: str + ) -> List[Dict[str, Any]]: + """Convert VectorItem objects to Pinecone point format.""" + points = [] + for item in items: + # Start with any existing metadata or an empty dict + metadata = item.get("metadata", {}).copy() if item.get("metadata") else {} + + # Add text to metadata if available + if "text" in item: + metadata["text"] = item["text"] + + # Always add collection_name to metadata for filtering + metadata["collection_name"] = collection_name_with_prefix + + point = { + "id": item["id"], + "values": item["vector"], + "metadata": metadata, + } + points.append(point) + return points + + def _get_collection_name_with_prefix(self, collection_name: str) -> str: + """Get the collection name with prefix.""" + return f"{self.collection_prefix}_{collection_name}" + + def _normalize_distance(self, score: float) -> float: + """Normalize distance score based on the metric used.""" + if self.metric.lower() == "cosine": + # Cosine similarity ranges from -1 to 1, normalize to 0 to 1 + return (score + 1.0) / 2.0 + elif self.metric.lower() in ["euclidean", "dotproduct"]: + # These are already suitable for ranking (smaller is better for Euclidean) + return score + else: + # For other metrics, use as is + return score + + def _result_to_get_result(self, matches: list) -> GetResult: + """Convert Pinecone matches to GetResult format.""" + ids = [] + documents = [] + metadatas = [] + + for match in matches: + metadata = match.get("metadata", {}) + ids.append(match["id"]) + documents.append(metadata.get("text", "")) + metadatas.append(metadata) + + return GetResult( + **{ + "ids": [ids], + "documents": [documents], + "metadatas": [metadatas], + } + ) + + def has_collection(self, collection_name: str) -> bool: + """Check if a collection exists by searching for at least one item.""" + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + + try: + # Search for at least 1 item with this collection name in metadata + response = self.index.query( + vector=[0.0] * self.dimension, # dummy vector + top_k=1, + filter={"collection_name": collection_name_with_prefix}, + include_metadata=False, + ) + return len(response.matches) > 0 + except Exception as e: + log.exception( + f"Error checking collection '{collection_name_with_prefix}': {e}" + ) + return False + + def delete_collection(self, collection_name: str) -> None: + """Delete a collection by removing all vectors with the collection name in metadata.""" + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + try: + self.index.delete(filter={"collection_name": collection_name_with_prefix}) + log.info( + f"Collection '{collection_name_with_prefix}' deleted (all vectors removed)." + ) + except Exception as e: + log.warning( + f"Failed to delete collection '{collection_name_with_prefix}': {e}" + ) + raise + + def insert(self, collection_name: str, items: List[VectorItem]) -> None: + """Insert vectors into a collection.""" + if not items: + log.warning("No items to insert") + return + + start_time = time.time() + + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + points = self._create_points(items, collection_name_with_prefix) + + # Parallelize batch inserts for performance + executor = self._executor + futures = [] + for i in range(0, len(points), BATCH_SIZE): + batch = points[i : i + BATCH_SIZE] + futures.append(executor.submit(self.index.upsert, vectors=batch)) + for future in concurrent.futures.as_completed(futures): + try: + future.result() + except Exception as e: + log.error(f"Error inserting batch: {e}") + raise + elapsed = time.time() - start_time + log.debug(f"Insert of {len(points)} vectors took {elapsed:.2f} seconds") + log.info( + f"Successfully inserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'" + ) + + def upsert(self, collection_name: str, items: List[VectorItem]) -> None: + """Upsert (insert or update) vectors into a collection.""" + if not items: + log.warning("No items to upsert") + return + + start_time = time.time() + + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + points = self._create_points(items, collection_name_with_prefix) + + # Parallelize batch upserts for performance + executor = self._executor + futures = [] + for i in range(0, len(points), BATCH_SIZE): + batch = points[i : i + BATCH_SIZE] + futures.append(executor.submit(self.index.upsert, vectors=batch)) + for future in concurrent.futures.as_completed(futures): + try: + future.result() + except Exception as e: + log.error(f"Error upserting batch: {e}") + raise + elapsed = time.time() - start_time + log.debug(f"Upsert of {len(points)} vectors took {elapsed:.2f} seconds") + log.info( + f"Successfully upserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'" + ) + + async def insert_async(self, collection_name: str, items: List[VectorItem]) -> None: + """Async version of insert using asyncio and run_in_executor for improved performance.""" + if not items: + log.warning("No items to insert") + return + + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + points = self._create_points(items, collection_name_with_prefix) + + # Create batches + batches = [ + points[i : i + BATCH_SIZE] for i in range(0, len(points), BATCH_SIZE) + ] + loop = asyncio.get_event_loop() + tasks = [ + loop.run_in_executor( + None, functools.partial(self.index.upsert, vectors=batch) + ) + for batch in batches + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + log.error(f"Error in async insert batch: {result}") + raise result + log.info( + f"Successfully async inserted {len(points)} vectors in batches into '{collection_name_with_prefix}'" + ) + + async def upsert_async(self, collection_name: str, items: List[VectorItem]) -> None: + """Async version of upsert using asyncio and run_in_executor for improved performance.""" + if not items: + log.warning("No items to upsert") + return + + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + points = self._create_points(items, collection_name_with_prefix) + + # Create batches + batches = [ + points[i : i + BATCH_SIZE] for i in range(0, len(points), BATCH_SIZE) + ] + loop = asyncio.get_event_loop() + tasks = [ + loop.run_in_executor( + None, functools.partial(self.index.upsert, vectors=batch) + ) + for batch in batches + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + log.error(f"Error in async upsert batch: {result}") + raise result + log.info( + f"Successfully async upserted {len(points)} vectors in batches into '{collection_name_with_prefix}'" + ) + + def streaming_upsert(self, collection_name: str, items: List[VectorItem]) -> None: + """Perform a streaming upsert over gRPC for performance testing.""" + if not items: + log.warning("No items to upsert via streaming") + return + + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + points = self._create_points(items, collection_name_with_prefix) + + # Open a streaming upsert channel + stream = self.index.streaming_upsert() + try: + for point in points: + # send each point over the stream + stream.send(point) + # close the stream to finalize + stream.close() + log.info( + f"Successfully streamed upsert of {len(points)} vectors into '{collection_name_with_prefix}'" + ) + except Exception as e: + log.error(f"Error during streaming upsert: {e}") + raise + + def search( + self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int + ) -> Optional[SearchResult]: + """Search for similar vectors in a collection.""" + if not vectors or not vectors[0]: + log.warning("No vectors provided for search") + return None + + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + + if limit is None or limit <= 0: + limit = NO_LIMIT + + try: + # Search using the first vector (assuming this is the intended behavior) + query_vector = vectors[0] + + # Perform the search + query_response = self.index.query( + vector=query_vector, + top_k=limit, + include_metadata=True, + filter={"collection_name": collection_name_with_prefix}, + ) + + if not query_response.matches: + # Return empty result if no matches + return SearchResult( + ids=[[]], + documents=[[]], + metadatas=[[]], + distances=[[]], + ) + + # Convert to GetResult format + get_result = self._result_to_get_result(query_response.matches) + + # Calculate normalized distances based on metric + distances = [ + [ + self._normalize_distance(match.score) + for match in query_response.matches + ] + ] + + return SearchResult( + ids=get_result.ids, + documents=get_result.documents, + metadatas=get_result.metadatas, + distances=distances, + ) + except Exception as e: + log.error(f"Error searching in '{collection_name_with_prefix}': {e}") + return None + + def query( + self, collection_name: str, filter: Dict, limit: Optional[int] = None + ) -> Optional[GetResult]: + """Query vectors by metadata filter.""" + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + + if limit is None or limit <= 0: + limit = NO_LIMIT + + try: + # Create a zero vector for the dimension as Pinecone requires a vector + zero_vector = [0.0] * self.dimension + + # Combine user filter with collection_name + pinecone_filter = {"collection_name": collection_name_with_prefix} + if filter: + pinecone_filter.update(filter) + + # Perform metadata-only query + query_response = self.index.query( + vector=zero_vector, + filter=pinecone_filter, + top_k=limit, + include_metadata=True, + ) + + return self._result_to_get_result(query_response.matches) + + except Exception as e: + log.error(f"Error querying collection '{collection_name}': {e}") + return None + + def get(self, collection_name: str) -> Optional[GetResult]: + """Get all vectors in a collection.""" + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + + try: + # Use a zero vector for fetching all entries + zero_vector = [0.0] * self.dimension + + # Add filter to only get vectors for this collection + query_response = self.index.query( + vector=zero_vector, + top_k=NO_LIMIT, + include_metadata=True, + filter={"collection_name": collection_name_with_prefix}, + ) + + return self._result_to_get_result(query_response.matches) + + except Exception as e: + log.error(f"Error getting collection '{collection_name}': {e}") + return None + + def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + filter: Optional[Dict] = None, + ) -> None: + """Delete vectors by IDs or filter.""" + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + + try: + if ids: + # Delete by IDs (in batches for large deletions) + for i in range(0, len(ids), BATCH_SIZE): + batch_ids = ids[i : i + BATCH_SIZE] + # Note: When deleting by ID, we can't filter by collection_name + # This is a limitation of Pinecone - be careful with ID uniqueness + self.index.delete(ids=batch_ids) + log.debug( + f"Deleted batch of {len(batch_ids)} vectors by ID from '{collection_name_with_prefix}'" + ) + log.info( + f"Successfully deleted {len(ids)} vectors by ID from '{collection_name_with_prefix}'" + ) + + elif filter: + # Combine user filter with collection_name + pinecone_filter = {"collection_name": collection_name_with_prefix} + if filter: + pinecone_filter.update(filter) + # Delete by metadata filter + self.index.delete(filter=pinecone_filter) + log.info( + f"Successfully deleted vectors by filter from '{collection_name_with_prefix}'" + ) + + else: + log.warning("No ids or filter provided for delete operation") + + except Exception as e: + log.error(f"Error deleting from collection '{collection_name}': {e}") + raise + + def reset(self) -> None: + """Reset the database by deleting all collections.""" + try: + self.index.delete(delete_all=True) + log.info("All vectors successfully deleted from the index.") + except Exception as e: + log.error(f"Failed to reset Pinecone index: {e}") + raise + + def close(self): + """Shut down the gRPC channel and thread pool.""" + try: + self.client.close() + log.info("Pinecone gRPC channel closed.") + except Exception as e: + log.warning(f"Failed to close Pinecone gRPC channel: {e}") + self._executor.shutdown(wait=True) + + def __enter__(self): + """Enter context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Exit context manager, ensuring resources are cleaned up.""" + self.close() diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py index be0df6c6ac..dfe2979076 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant.py @@ -1,12 +1,24 @@ from typing import Optional import logging +from urllib.parse import urlparse from qdrant_client import QdrantClient as Qclient from qdrant_client.http.models import PointStruct from qdrant_client.models import models -from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult -from open_webui.config import QDRANT_URI, QDRANT_API_KEY +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) +from open_webui.config import ( + QDRANT_URI, + QDRANT_API_KEY, + QDRANT_ON_DISK, + QDRANT_GRPC_PORT, + QDRANT_PREFER_GRPC, +) from open_webui.env import SRC_LOG_LEVELS NO_LIMIT = 999999999 @@ -15,16 +27,34 @@ log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) -class QdrantClient: +class QdrantClient(VectorDBBase): def __init__(self): self.collection_prefix = "open-webui" self.QDRANT_URI = QDRANT_URI self.QDRANT_API_KEY = QDRANT_API_KEY - self.client = ( - Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY) - if self.QDRANT_URI - else None - ) + self.QDRANT_ON_DISK = QDRANT_ON_DISK + self.PREFER_GRPC = QDRANT_PREFER_GRPC + self.GRPC_PORT = QDRANT_GRPC_PORT + + if not self.QDRANT_URI: + self.client = None + return + + # Unified handling for either scheme + parsed = urlparse(self.QDRANT_URI) + host = parsed.hostname or self.QDRANT_URI + http_port = parsed.port or 6333 # default REST port + + if self.PREFER_GRPC: + self.client = Qclient( + host=host, + port=http_port, + grpc_port=self.GRPC_PORT, + prefer_grpc=self.PREFER_GRPC, + api_key=self.QDRANT_API_KEY, + ) + else: + self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY) def _result_to_get_result(self, points) -> GetResult: ids = [] @@ -50,7 +80,9 @@ class QdrantClient: self.client.create_collection( collection_name=collection_name_with_prefix, vectors_config=models.VectorParams( - size=dimension, distance=models.Distance.COSINE + size=dimension, + distance=models.Distance.COSINE, + on_disk=self.QDRANT_ON_DISK, ), ) diff --git a/backend/open_webui/retrieval/vector/main.py b/backend/open_webui/retrieval/vector/main.py index f0cf0c0387..53f752f579 100644 --- a/backend/open_webui/retrieval/vector/main.py +++ b/backend/open_webui/retrieval/vector/main.py @@ -1,5 +1,6 @@ from pydantic import BaseModel -from typing import Optional, List, Any +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Union class VectorItem(BaseModel): @@ -17,3 +18,69 @@ class GetResult(BaseModel): class SearchResult(GetResult): distances: Optional[List[List[float | int]]] + + +class VectorDBBase(ABC): + """ + Abstract base class for all vector database backends. + + Implementations of this class provide methods for collection management, + vector insertion, deletion, similarity search, and metadata filtering. + + Any custom vector database integration must inherit from this class and + implement all abstract methods. + """ + + @abstractmethod + def has_collection(self, collection_name: str) -> bool: + """Check if the collection exists in the vector DB.""" + pass + + @abstractmethod + def delete_collection(self, collection_name: str) -> None: + """Delete a collection from the vector DB.""" + pass + + @abstractmethod + def insert(self, collection_name: str, items: List[VectorItem]) -> None: + """Insert a list of vector items into a collection.""" + pass + + @abstractmethod + def upsert(self, collection_name: str, items: List[VectorItem]) -> None: + """Insert or update vector items in a collection.""" + pass + + @abstractmethod + def search( + self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int + ) -> Optional[SearchResult]: + """Search for similar vectors in a collection.""" + pass + + @abstractmethod + def query( + self, collection_name: str, filter: Dict, limit: Optional[int] = None + ) -> Optional[GetResult]: + """Query vectors from a collection using metadata filter.""" + pass + + @abstractmethod + def get(self, collection_name: str) -> Optional[GetResult]: + """Retrieve all vectors from a collection.""" + pass + + @abstractmethod + def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + filter: Optional[Dict] = None, + ) -> None: + """Delete vectors by ID or filter from a collection.""" + pass + + @abstractmethod + def reset(self) -> None: + """Reset the vector database by removing all collections or those matching a condition.""" + pass diff --git a/backend/open_webui/retrieval/web/external.py b/backend/open_webui/retrieval/web/external.py new file mode 100644 index 0000000000..a5c8003e47 --- /dev/null +++ b/backend/open_webui/retrieval/web/external.py @@ -0,0 +1,47 @@ +import logging +from typing import Optional, List + +import requests +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_external( + external_url: str, + external_api_key: str, + query: str, + count: int, + filter_list: Optional[List[str]] = None, +) -> List[SearchResult]: + try: + response = requests.post( + external_url, + headers={ + "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", + "Authorization": f"Bearer {external_api_key}", + }, + json={ + "query": query, + "count": count, + }, + ) + response.raise_for_status() + results = response.json() + if filter_list: + results = get_filtered_results(results, filter_list) + results = [ + SearchResult( + link=result.get("link"), + title=result.get("title"), + snippet=result.get("snippet"), + ) + for result in results[:count] + ] + log.info(f"External search results: {results}") + return results + except Exception as e: + log.error(f"Error in External search: {e}") + return [] diff --git a/backend/open_webui/retrieval/web/firecrawl.py b/backend/open_webui/retrieval/web/firecrawl.py new file mode 100644 index 0000000000..a85fc51fbd --- /dev/null +++ b/backend/open_webui/retrieval/web/firecrawl.py @@ -0,0 +1,49 @@ +import logging +from typing import Optional, List +from urllib.parse import urljoin + +import requests +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_firecrawl( + firecrawl_url: str, + firecrawl_api_key: str, + query: str, + count: int, + filter_list: Optional[List[str]] = None, +) -> List[SearchResult]: + try: + firecrawl_search_url = urljoin(firecrawl_url, "/v1/search") + response = requests.post( + firecrawl_search_url, + headers={ + "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", + "Authorization": f"Bearer {firecrawl_api_key}", + }, + json={ + "query": query, + "limit": count, + }, + ) + response.raise_for_status() + results = response.json().get("data", []) + if filter_list: + results = get_filtered_results(results, filter_list) + results = [ + SearchResult( + link=result.get("url"), + title=result.get("title"), + snippet=result.get("description"), + ) + for result in results[:count] + ] + log.info(f"External search results: {results}") + return results + except Exception as e: + log.error(f"Error in External search: {e}") + return [] diff --git a/backend/open_webui/retrieval/web/tavily.py b/backend/open_webui/retrieval/web/tavily.py index da70aa8e7f..bfd102afa6 100644 --- a/backend/open_webui/retrieval/web/tavily.py +++ b/backend/open_webui/retrieval/web/tavily.py @@ -2,7 +2,7 @@ import logging from typing import Optional import requests -from open_webui.retrieval.web.main import SearchResult +from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) @@ -21,18 +21,25 @@ def search_tavily( Args: api_key (str): A Tavily Search API key query (str): The query to search for + count (int): The maximum number of results to return Returns: list[SearchResult]: A list of search results """ url = "https://api.tavily.com/search" - data = {"query": query, "api_key": api_key} - response = requests.post(url, json=data) + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + data = {"query": query, "max_results": count} + response = requests.post(url, headers=headers, json=data) response.raise_for_status() json_response = response.json() - raw_search_results = json_response.get("results", []) + results = json_response.get("results", []) + if filter_list: + results = get_filtered_results(results, filter_list) return [ SearchResult( @@ -40,5 +47,5 @@ def search_tavily( title=result.get("title", ""), snippet=result.get("content"), ) - for result in raw_search_results[:count] + for result in results ] diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 718cfe52fa..78c962f158 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -25,6 +25,7 @@ from langchain_community.document_loaders.firecrawl import FireCrawlLoader from langchain_community.document_loaders.base import BaseLoader from langchain_core.documents import Document from open_webui.retrieval.loaders.tavily import TavilyLoader +from open_webui.retrieval.loaders.external import ExternalLoader from open_webui.constants import ERROR_MESSAGES from open_webui.config import ( ENABLE_RAG_LOCAL_WEB_FETCH, @@ -35,6 +36,8 @@ from open_webui.config import ( FIRECRAWL_API_KEY, TAVILY_API_KEY, TAVILY_EXTRACT_DEPTH, + EXTERNAL_WEB_LOADER_URL, + EXTERNAL_WEB_LOADER_API_KEY, ) from open_webui.env import SRC_LOG_LEVELS @@ -167,7 +170,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): continue_on_failure: bool = True, api_key: Optional[str] = None, api_url: Optional[str] = None, - mode: Literal["crawl", "scrape", "map"] = "crawl", + mode: Literal["crawl", "scrape", "map"] = "scrape", proxy: Optional[Dict[str, str]] = None, params: Optional[Dict] = None, ): @@ -225,7 +228,10 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): mode=self.mode, params=self.params, ) - yield from loader.lazy_load() + for document in loader.lazy_load(): + if not document.metadata.get("source"): + document.metadata["source"] = document.metadata.get("sourceURL") + yield document except Exception as e: if self.continue_on_failure: log.exception(f"Error loading {url}: {e}") @@ -245,6 +251,8 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): params=self.params, ) async for document in loader.alazy_load(): + if not document.metadata.get("source"): + document.metadata["source"] = document.metadata.get("sourceURL") yield document except Exception as e: if self.continue_on_failure: @@ -619,6 +627,11 @@ def get_web_loader( web_loader_args["api_key"] = TAVILY_API_KEY.value web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value + if WEB_LOADER_ENGINE.value == "external": + WebLoaderClass = ExternalLoader + web_loader_args["external_url"] = EXTERNAL_WEB_LOADER_URL.value + web_loader_args["external_api_key"] = EXTERNAL_WEB_LOADER_API_KEY.value + if WebLoaderClass: web_loader = WebLoaderClass(**web_loader_args) diff --git a/backend/open_webui/retrieval/web/yacy.py b/backend/open_webui/retrieval/web/yacy.py new file mode 100644 index 0000000000..bc61425cbc --- /dev/null +++ b/backend/open_webui/retrieval/web/yacy.py @@ -0,0 +1,87 @@ +import logging +from typing import Optional + +import requests +from requests.auth import HTTPDigestAuth +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_yacy( + query_url: str, + username: Optional[str], + password: Optional[str], + query: str, + count: int, + filter_list: Optional[list[str]] = None, +) -> list[SearchResult]: + """ + Search a Yacy instance for a given query and return the results as a list of SearchResult objects. + + The function accepts username and password for authenticating to Yacy. + + Args: + query_url (str): The base URL of the Yacy server. + username (str): Optional YaCy username. + password (str): Optional YaCy password. + query (str): The search term or question to find in the Yacy database. + count (int): The maximum number of results to retrieve from the search. + + Returns: + list[SearchResult]: A list of SearchResults sorted by relevance score in descending order. + + Raise: + requests.exceptions.RequestException: If a request error occurs during the search process. + """ + + # Use authentication if either username or password is set + yacy_auth = None + if username or password: + yacy_auth = HTTPDigestAuth(username, password) + + params = { + "query": query, + "contentdom": "text", + "resource": "global", + "maximumRecords": count, + "nav": "none", + } + + # Check if provided a json API URL + if not query_url.endswith("yacysearch.json"): + # Strip all query parameters from the URL + query_url = query_url.rstrip("/") + "/yacysearch.json" + + log.debug(f"searching {query_url}") + + response = requests.get( + query_url, + auth=yacy_auth, + headers={ + "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", + "Accept": "text/html", + "Accept-Encoding": "gzip, deflate", + "Accept-Language": "en-US,en;q=0.5", + "Connection": "keep-alive", + }, + params=params, + ) + + response.raise_for_status() # Raise an exception for HTTP errors. + + json_response = response.json() + results = json_response.get("channels", [{}])[0].get("items", []) + sorted_results = sorted(results, key=lambda x: x.get("ranking", 0), reverse=True) + if filter_list: + sorted_results = get_filtered_results(sorted_results, filter_list) + return [ + SearchResult( + link=result["link"], + title=result.get("title"), + snippet=result.get("description"), + ) + for result in sorted_results[:count] + ] diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index da51d1ecfc..445857c88e 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -33,6 +33,7 @@ from open_webui.config import ( WHISPER_MODEL_AUTO_UPDATE, WHISPER_MODEL_DIR, CACHE_DIR, + WHISPER_LANGUAGE, ) from open_webui.constants import ERROR_MESSAGES @@ -70,23 +71,27 @@ from pydub import AudioSegment from pydub.utils import mediainfo -def get_audio_format(file_path): +def get_audio_convert_format(file_path): """Check if the given file needs to be converted to a different format.""" if not os.path.isfile(file_path): log.error(f"File not found: {file_path}") return False - info = mediainfo(file_path) - if ( - info.get("codec_name") == "aac" - and info.get("codec_type") == "audio" - and info.get("codec_tag_string") == "mp4a" - ): - return "mp4" - elif info.get("format_name") == "ogg": - return "ogg" - elif info.get("format_name") == "matroska,webm": - return "webm" + try: + info = mediainfo(file_path) + + if ( + info.get("codec_name") == "aac" + and info.get("codec_type") == "audio" + and info.get("codec_tag_string") == "mp4a" + ): + return "mp4" + elif info.get("format_name") == "ogg": + return "ogg" + except Exception as e: + log.error(f"Error getting audio format: {e}") + return False + return None @@ -137,6 +142,7 @@ class TTSConfigForm(BaseModel): VOICE: str SPLIT_ON: str AZURE_SPEECH_REGION: str + AZURE_SPEECH_BASE_URL: str AZURE_SPEECH_OUTPUT_FORMAT: str @@ -150,6 +156,8 @@ class STTConfigForm(BaseModel): AZURE_API_KEY: str AZURE_REGION: str AZURE_LOCALES: str + AZURE_BASE_URL: str + AZURE_MAX_SPEAKERS: str class AudioConfigUpdateForm(BaseModel): @@ -169,6 +177,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)): "VOICE": request.app.state.config.TTS_VOICE, "SPLIT_ON": request.app.state.config.TTS_SPLIT_ON, "AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION, + "AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, "AZURE_SPEECH_OUTPUT_FORMAT": request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, }, "stt": { @@ -181,6 +190,8 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)): "AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY, "AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION, "AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES, + "AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL, + "AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS, }, } @@ -197,6 +208,9 @@ async def update_audio_config( request.app.state.config.TTS_VOICE = form_data.tts.VOICE request.app.state.config.TTS_SPLIT_ON = form_data.tts.SPLIT_ON request.app.state.config.TTS_AZURE_SPEECH_REGION = form_data.tts.AZURE_SPEECH_REGION + request.app.state.config.TTS_AZURE_SPEECH_BASE_URL = ( + form_data.tts.AZURE_SPEECH_BASE_URL + ) request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = ( form_data.tts.AZURE_SPEECH_OUTPUT_FORMAT ) @@ -210,6 +224,10 @@ async def update_audio_config( request.app.state.config.AUDIO_STT_AZURE_API_KEY = form_data.stt.AZURE_API_KEY request.app.state.config.AUDIO_STT_AZURE_REGION = form_data.stt.AZURE_REGION request.app.state.config.AUDIO_STT_AZURE_LOCALES = form_data.stt.AZURE_LOCALES + request.app.state.config.AUDIO_STT_AZURE_BASE_URL = form_data.stt.AZURE_BASE_URL + request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = ( + form_data.stt.AZURE_MAX_SPEAKERS + ) if request.app.state.config.STT_ENGINE == "": request.app.state.faster_whisper_model = set_faster_whisper_model( @@ -226,6 +244,7 @@ async def update_audio_config( "VOICE": request.app.state.config.TTS_VOICE, "SPLIT_ON": request.app.state.config.TTS_SPLIT_ON, "AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION, + "AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, "AZURE_SPEECH_OUTPUT_FORMAT": request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, }, "stt": { @@ -238,6 +257,8 @@ async def update_audio_config( "AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY, "AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION, "AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES, + "AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL, + "AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS, }, } @@ -395,7 +416,8 @@ async def speech(request: Request, user=Depends(get_verified_user)): log.exception(e) raise HTTPException(status_code=400, detail="Invalid JSON payload") - region = request.app.state.config.TTS_AZURE_SPEECH_REGION + region = request.app.state.config.TTS_AZURE_SPEECH_REGION or "eastus" + base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL language = request.app.state.config.TTS_VOICE locale = "-".join(request.app.state.config.TTS_VOICE.split("-")[:1]) output_format = request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT @@ -409,7 +431,8 @@ async def speech(request: Request, user=Depends(get_verified_user)): timeout=timeout, trust_env=True ) as session: async with session.post( - f"https://{region}.tts.speech.microsoft.com/cognitiveservices/v1", + (base_url or f"https://{region}.tts.speech.microsoft.com") + + "/cognitiveservices/v1", headers={ "Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY, "Content-Type": "application/ssml+xml", @@ -501,6 +524,7 @@ def transcribe(request: Request, file_path): file_path, beam_size=5, vad_filter=request.app.state.config.WHISPER_VAD_FILTER, + language=WHISPER_LANGUAGE, ) log.info( "Detected language '%s' with probability %f" @@ -518,14 +542,17 @@ def transcribe(request: Request, file_path): log.debug(data) return data elif request.app.state.config.STT_ENGINE == "openai": - audio_format = get_audio_format(file_path) - if audio_format: - os.rename(file_path, file_path.replace(".wav", f".{audio_format}")) + convert_format = get_audio_convert_format(file_path) + + if convert_format: + ext = convert_format.split(".")[-1] + + os.rename(file_path, file_path.replace(".{ext}", f".{convert_format}")) # Convert unsupported audio file to WAV format convert_audio_to_wav( - file_path.replace(".wav", f".{audio_format}"), + file_path.replace(".{ext}", f".{convert_format}"), file_path, - audio_format, + convert_format, ) r = None @@ -639,8 +666,10 @@ def transcribe(request: Request, file_path): ) api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY - region = request.app.state.config.AUDIO_STT_AZURE_REGION + region = request.app.state.config.AUDIO_STT_AZURE_REGION or "eastus" locales = request.app.state.config.AUDIO_STT_AZURE_LOCALES + base_url = request.app.state.config.AUDIO_STT_AZURE_BASE_URL + max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS or 3 # IF NO LOCALES, USE DEFAULTS if len(locales) < 2: @@ -664,7 +693,7 @@ def transcribe(request: Request, file_path): if not api_key or not region: raise HTTPException( status_code=400, - detail="Azure API key and region are required for Azure STT", + detail="Azure API key is required for Azure STT", ) r = None @@ -674,13 +703,16 @@ def transcribe(request: Request, file_path): "definition": json.dumps( { "locales": locales.split(","), - "diarization": {"maxSpeakers": 3, "enabled": True}, + "diarization": {"maxSpeakers": max_speakers, "enabled": True}, } if locales else {} ) } - url = f"https://{region}.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe?api-version=2024-11-15" + + url = ( + base_url or f"https://{region}.api.cognitive.microsoft.com" + ) + "/speechtotext/transcriptions:transcribe?api-version=2024-11-15" # Use context manager to ensure file is properly closed with open(file_path, "rb") as audio_file: @@ -765,7 +797,13 @@ def transcription( ): log.info(f"file.content_type: {file.content_type}") - supported_filetypes = ("audio/mpeg", "audio/wav", "audio/ogg", "audio/x-m4a") + supported_filetypes = ( + "audio/mpeg", + "audio/wav", + "audio/ogg", + "audio/x-m4a", + "audio/webm", + ) if not file.content_type.startswith(supported_filetypes): raise HTTPException( @@ -909,7 +947,10 @@ def get_available_voices(request) -> dict: elif request.app.state.config.TTS_ENGINE == "azure": try: region = request.app.state.config.TTS_AZURE_SPEECH_REGION - url = f"https://{region}.tts.speech.microsoft.com/cognitiveservices/voices/list" + base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL + url = ( + base_url or f"https://{region}.tts.speech.microsoft.com" + ) + "/cognitiveservices/voices/list" headers = { "Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY } diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 9c4d5cb9f6..309862ed55 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -27,20 +27,24 @@ from open_webui.env import ( WEBUI_AUTH_TRUSTED_NAME_HEADER, WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, + WEBUI_AUTH_SIGNOUT_REDIRECT_URL, SRC_LOG_LEVELS, ) from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import RedirectResponse, Response from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP from pydantic import BaseModel + from open_webui.utils.misc import parse_duration, validate_email_format from open_webui.utils.auth import ( + decode_token, create_api_key, create_token, get_admin_user, get_verified_user, get_current_user, get_password_hash, + get_http_authorization_cred, ) from open_webui.utils.webhook import post_webhook from open_webui.utils.access_control import get_permissions @@ -72,31 +76,36 @@ class SessionUserResponse(Token, UserResponse): async def get_session_user( request: Request, response: Response, user=Depends(get_current_user) ): - expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) + + auth_header = request.headers.get("Authorization") + auth_token = get_http_authorization_cred(auth_header) + token = auth_token.credentials + data = decode_token(token) + expires_at = None - if expires_delta: - expires_at = int(time.time()) + int(expires_delta.total_seconds()) - token = create_token( - data={"id": user.id}, - expires_delta=expires_delta, - ) + if data: + expires_at = data.get("exp") - datetime_expires_at = ( - datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) - if expires_at - else None - ) + if (expires_at is not None) and int(time.time()) > expires_at: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.INVALID_TOKEN, + ) - # Set the cookie token - response.set_cookie( - key="token", - value=token, - expires=datetime_expires_at, - httponly=True, # Ensures the cookie is not accessible via JavaScript - samesite=WEBUI_AUTH_COOKIE_SAME_SITE, - secure=WEBUI_AUTH_COOKIE_SECURE, - ) + # Set the cookie token + response.set_cookie( + key="token", + value=token, + expires=( + datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) + if expires_at + else None + ), + httponly=True, # Ensures the cookie is not accessible via JavaScript + samesite=WEBUI_AUTH_COOKIE_SAME_SITE, + secure=WEBUI_AUTH_COOKIE_SECURE, + ) user_permissions = get_permissions( user.id, request.app.state.config.USER_PERMISSIONS @@ -225,12 +234,14 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): ], ) - if not search_success: + if not search_success or not connection_app.entries: raise HTTPException(400, detail="User not found in the LDAP server") entry = connection_app.entries[0] username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower() - email = entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"].value # retrive the Attribute value + email = entry[ + f"{LDAP_ATTRIBUTE_FOR_MAIL}" + ].value # retrieve the Attribute value if not email: raise HTTPException(400, "User does not have a valid email address.") elif isinstance(email, str): @@ -288,18 +299,30 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): user = Auths.authenticate_user_by_trusted_header(email) if user: + expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) + expires_at = None + if expires_delta: + expires_at = int(time.time()) + int(expires_delta.total_seconds()) + token = create_token( data={"id": user.id}, - expires_delta=parse_duration( - request.app.state.config.JWT_EXPIRES_IN - ), + expires_delta=expires_delta, ) # Set the cookie token response.set_cookie( key="token", value=token, + expires=( + datetime.datetime.fromtimestamp( + expires_at, datetime.timezone.utc + ) + if expires_at + else None + ), httponly=True, # Ensures the cookie is not accessible via JavaScript + samesite=WEBUI_AUTH_COOKIE_SAME_SITE, + secure=WEBUI_AUTH_COOKIE_SECURE, ) user_permissions = get_permissions( @@ -309,6 +332,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): return { "token": token, "token_type": "Bearer", + "expires_at": expires_at, "id": user.id, "email": user.email, "name": user.name, @@ -566,6 +590,12 @@ async def signout(request: Request, response: Response): detail="Failed to sign out from the OpenID provider.", ) + if WEBUI_AUTH_SIGNOUT_REDIRECT_URL: + return RedirectResponse( + headers=response.headers, + url=WEBUI_AUTH_SIGNOUT_REDIRECT_URL, + ) + return {"status": True} @@ -664,6 +694,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)): "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, + "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, } @@ -680,6 +711,7 @@ class AdminConfig(BaseModel): ENABLE_COMMUNITY_SHARING: bool ENABLE_MESSAGE_RATING: bool ENABLE_CHANNELS: bool + ENABLE_NOTES: bool ENABLE_USER_WEBHOOKS: bool @@ -700,6 +732,7 @@ async def update_admin_config( ) request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS + request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]: request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE @@ -724,11 +757,12 @@ async def update_admin_config( "ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY, "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS, "API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS, - "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE, "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, + "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, + "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, } diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 5fd44ab9f0..6f00dd4d7c 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -638,8 +638,17 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/share", response_model=Optional[ChatResponse]) -async def share_chat_by_id(id: str, user=Depends(get_verified_user)): +async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)): + if not has_permission( + user.id, "chat.share", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + chat = Chats.get_chat_by_id_and_user_id(id, user.id) + if chat: if chat.share_id: shared_chat = Chats.update_shared_chat_by_chat_id(chat.id) diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index 8597fa2863..36320b6fc9 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -56,7 +56,7 @@ async def update_config( } -class FeedbackUserReponse(BaseModel): +class UserResponse(BaseModel): id: str name: str email: str @@ -68,7 +68,7 @@ class FeedbackUserReponse(BaseModel): class FeedbackUserResponse(FeedbackResponse): - user: Optional[FeedbackUserReponse] = None + user: Optional[UserResponse] = None @router.get("/feedbacks/all", response_model=list[FeedbackUserResponse]) @@ -77,9 +77,7 @@ async def get_all_feedbacks(user=Depends(get_admin_user)): return [ FeedbackUserResponse( **feedback.model_dump(), - user=FeedbackUserReponse( - **Users.get_user_by_id(feedback.user_id).model_dump() - ), + user=UserResponse(**Users.get_user_by_id(feedback.user_id).model_dump()), ) for feedback in feedbacks ] diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 8a2888d864..475905da1b 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -19,6 +19,8 @@ from fastapi import ( from fastapi.responses import FileResponse, StreamingResponse from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS + +from open_webui.models.users import Users from open_webui.models.files import ( FileForm, FileModel, @@ -83,10 +85,12 @@ def upload_file( request: Request, file: UploadFile = File(...), user=Depends(get_verified_user), - file_metadata: dict = {}, + file_metadata: dict = None, process: bool = Query(True), ): log.info(f"file.content_type: {file.content_type}") + + file_metadata = file_metadata if file_metadata else {} try: unsanitized_filename = file.filename filename = os.path.basename(unsanitized_filename) @@ -95,7 +99,13 @@ def upload_file( id = str(uuid.uuid4()) name = filename filename = f"{id}_{filename}" - contents, file_path = Storage.upload_file(file.file, filename) + tags = { + "OpenWebUI-User-Email": user.email, + "OpenWebUI-User-Id": user.id, + "OpenWebUI-User-Name": user.name, + "OpenWebUI-File-Id": id, + } + contents, file_path = Storage.upload_file(file.file, filename, tags) file_item = Files.insert_new_file( user.id, @@ -115,12 +125,17 @@ def upload_file( ) if process: try: - if file.content_type in [ - "audio/mpeg", - "audio/wav", - "audio/ogg", - "audio/x-m4a", - ]: + + if file.content_type.startswith( + ( + "audio/mpeg", + "audio/wav", + "audio/ogg", + "audio/x-m4a", + "audio/webm", + "video/webm", + ) + ): file_path = Storage.get_file(file_path) result = transcribe(request, file_path) @@ -129,7 +144,14 @@ def upload_file( ProcessFileForm(file_id=id, content=result.get("text", "")), user=user, ) - elif file.content_type not in ["image/png", "image/jpeg", "image/gif"]: + elif file.content_type not in [ + "image/png", + "image/jpeg", + "image/gif", + "video/mp4", + "video/ogg", + "video/quicktime", + ]: process_file(request, ProcessFileForm(file_id=id), user=user) file_item = Files.get_file_by_id(id=id) @@ -173,7 +195,8 @@ async def list_files(user=Depends(get_verified_user), content: bool = Query(True if not content: for file in files: - del file.data["content"] + if "content" in file.data: + del file.data["content"] return files @@ -214,7 +237,8 @@ async def search_files( if not content: for file in matching_files: - del file.data["content"] + if "content" in file.data: + del file.data["content"] return matching_files @@ -431,6 +455,13 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)): detail=ERROR_MESSAGES.NOT_FOUND, ) + file_user = Users.get_user_by_id(file.user_id) + if not file_user.role == "admin": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + if ( file.user_id == user.id or user.role == "admin" diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 275704f341..b8bb110f51 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -500,7 +500,11 @@ async def image_generations( if form_data.size else request.app.state.config.IMAGE_SIZE ), - "response_format": "b64_json", + **( + {} + if "gpt-image-1" in request.app.state.config.IMAGE_GENERATION_MODEL + else {"response_format": "b64_json"} + ), } # Use asyncio.to_thread for the requests.post call @@ -619,7 +623,7 @@ async def image_generations( or request.app.state.config.IMAGE_GENERATION_ENGINE == "" ): if form_data.model: - set_image_model(form_data.model) + set_image_model(request, form_data.model) data = { "prompt": form_data.prompt, diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 15547afa7c..920130858a 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -9,7 +9,7 @@ from open_webui.models.knowledge import ( KnowledgeResponse, KnowledgeUserResponse, ) -from open_webui.models.files import Files, FileModel +from open_webui.models.files import Files, FileModel, FileMetadataResponse from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT from open_webui.routers.retrieval import ( process_file, @@ -178,10 +178,26 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us log.info(f"Starting reindexing for {len(knowledge_bases)} knowledge bases") - for knowledge_base in knowledge_bases: - try: - files = Files.get_files_by_ids(knowledge_base.data.get("file_ids", [])) + deleted_knowledge_bases = [] + for knowledge_base in knowledge_bases: + # -- Robust error handling for missing or invalid data + if not knowledge_base.data or not isinstance(knowledge_base.data, dict): + log.warning( + f"Knowledge base {knowledge_base.id} has no data or invalid data ({knowledge_base.data!r}). Deleting." + ) + try: + Knowledges.delete_knowledge_by_id(id=knowledge_base.id) + deleted_knowledge_bases.append(knowledge_base.id) + except Exception as e: + log.error( + f"Failed to delete invalid knowledge base {knowledge_base.id}: {e}" + ) + continue + + try: + file_ids = knowledge_base.data.get("file_ids", []) + files = Files.get_files_by_ids(file_ids) try: if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id): VECTOR_DB_CLIENT.delete_collection( @@ -189,10 +205,7 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us ) except Exception as e: log.error(f"Error deleting collection {knowledge_base.id}: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error deleting vector DB collection", - ) + continue # Skip, don't raise failed_files = [] for file in files: @@ -213,10 +226,8 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us except Exception as e: log.error(f"Error processing knowledge base {knowledge_base.id}: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error processing knowledge base", - ) + # Don't raise, just continue + continue if failed_files: log.warning( @@ -225,7 +236,9 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us for failed in failed_files: log.warning(f"File ID: {failed['file_id']}, Error: {failed['error']}") - log.info("Reindexing completed successfully") + log.info( + f"Reindexing completed. Deleted {len(deleted_knowledge_bases)} invalid knowledge bases: {deleted_knowledge_bases}" + ) return True @@ -235,7 +248,7 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us class KnowledgeFilesResponse(KnowledgeResponse): - files: list[FileModel] + files: list[FileMetadataResponse] @router.get("/{id}", response_model=Optional[KnowledgeFilesResponse]) @@ -251,7 +264,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)): ): file_ids = knowledge.data.get("file_ids", []) if knowledge.data else [] - files = Files.get_files_by_ids(file_ids) + files = Files.get_file_metadatas_by_ids(file_ids) return KnowledgeFilesResponse( **knowledge.model_dump(), @@ -379,7 +392,7 @@ def add_file_to_knowledge_by_id( knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data) if knowledge: - files = Files.get_files_by_ids(file_ids) + files = Files.get_file_metadatas_by_ids(file_ids) return KnowledgeFilesResponse( **knowledge.model_dump(), @@ -456,7 +469,7 @@ def update_file_from_knowledge_by_id( data = knowledge.data or {} file_ids = data.get("file_ids", []) - files = Files.get_files_by_ids(file_ids) + files = Files.get_file_metadatas_by_ids(file_ids) return KnowledgeFilesResponse( **knowledge.model_dump(), @@ -538,7 +551,7 @@ def remove_file_from_knowledge_by_id( knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data) if knowledge: - files = Files.get_files_by_ids(file_ids) + files = Files.get_file_metadatas_by_ids(file_ids) return KnowledgeFilesResponse( **knowledge.model_dump(), @@ -734,7 +747,7 @@ def add_files_to_knowledge_batch( error_details = [f"{err.file_id}: {err.error}" for err in result.errors] return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Files.get_files_by_ids(existing_file_ids), + files=Files.get_file_metadatas_by_ids(existing_file_ids), warnings={ "message": "Some files failed to process", "errors": error_details, @@ -742,5 +755,6 @@ def add_files_to_knowledge_batch( ) return KnowledgeFilesResponse( - **knowledge.model_dump(), files=Files.get_files_by_ids(existing_file_ids) + **knowledge.model_dump(), + files=Files.get_file_metadatas_by_ids(existing_file_ids), ) diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py new file mode 100644 index 0000000000..5ad5ff051e --- /dev/null +++ b/backend/open_webui/routers/notes.py @@ -0,0 +1,218 @@ +import json +import logging +from typing import Optional + + +from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks +from pydantic import BaseModel + +from open_webui.models.users import Users, UserResponse +from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse + +from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT +from open_webui.constants import ERROR_MESSAGES +from open_webui.env import SRC_LOG_LEVELS + + +from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.access_control import has_access, has_permission + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + +router = APIRouter() + +############################ +# GetNotes +############################ + + +@router.get("/", response_model=list[NoteUserResponse]) +async def get_notes(request: Request, user=Depends(get_verified_user)): + + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + notes = [ + NoteUserResponse( + **{ + **note.model_dump(), + "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), + } + ) + for note in Notes.get_notes_by_user_id(user.id, "write") + ] + + return notes + + +@router.get("/list", response_model=list[NoteUserResponse]) +async def get_note_list(request: Request, user=Depends(get_verified_user)): + + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + notes = [ + NoteUserResponse( + **{ + **note.model_dump(), + "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), + } + ) + for note in Notes.get_notes_by_user_id(user.id, "read") + ] + + return notes + + +############################ +# CreateNewNote +############################ + + +@router.post("/create", response_model=Optional[NoteModel]) +async def create_new_note( + request: Request, form_data: NoteForm, user=Depends(get_verified_user) +): + + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + try: + note = Notes.insert_new_note(form_data, user.id) + return note + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + +############################ +# GetNoteById +############################ + + +@router.get("/{id}", response_model=Optional[NoteModel]) +async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = Notes.get_note_by_id(id) + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if ( + user.role != "admin" + and user.id != note.user_id + and not has_access(user.id, type="read", access_control=note.access_control) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + return note + + +############################ +# UpdateNoteById +############################ + + +@router.post("/{id}/update", response_model=Optional[NoteModel]) +async def update_note_by_id( + request: Request, id: str, form_data: NoteForm, user=Depends(get_verified_user) +): + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = Notes.get_note_by_id(id) + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if ( + user.role != "admin" + and user.id != note.user_id + and not has_access(user.id, type="write", access_control=note.access_control) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + try: + note = Notes.update_note_by_id(id, form_data) + return note + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + +############################ +# DeleteNoteById +############################ + + +@router.delete("/{id}/delete", response_model=bool) +async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = Notes.get_note_by_id(id) + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if ( + user.role != "admin" + and user.id != note.user_id + and not has_access(user.id, type="write", access_control=note.access_control) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + try: + note = Notes.delete_note_by_id(id) + return True + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 775cd04465..790f7dece3 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -54,6 +54,7 @@ from open_webui.config import ( from open_webui.env import ( ENV, SRC_LOG_LEVELS, + AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, BYPASS_MODEL_ACCESS_CONTROL, @@ -91,6 +92,7 @@ async def send_get_request(url, key=None, user: UserModel = None): else {} ), }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: return await response.json() except Exception as e: @@ -141,6 +143,7 @@ async def send_post_request( else {} ), }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) r.raise_for_status() @@ -216,7 +219,8 @@ async def verify_connection( key = form_data.key async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), ) as session: try: async with session.get( @@ -234,6 +238,7 @@ async def verify_connection( else {} ), }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: if r.status != 200: detail = f"HTTP Error: {r.status}" @@ -878,8 +883,16 @@ async def embed( ) url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(url_idx), + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support + ) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + prefix_id = api_config.get("prefix_id", None) + if prefix_id: + form_data.model = form_data.model.replace(f"{prefix_id}.", "") + try: r = requests.request( method="POST", @@ -957,8 +970,16 @@ async def embeddings( ) url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(url_idx), + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support + ) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + prefix_id = api_config.get("prefix_id", None) + if prefix_id: + form_data.model = form_data.model.replace(f"{prefix_id}.", "") + try: r = requests.request( method="POST", @@ -1006,7 +1027,7 @@ class GenerateCompletionForm(BaseModel): prompt: str suffix: Optional[str] = None images: Optional[list[str]] = None - format: Optional[str] = None + format: Optional[Union[dict, str]] = None options: Optional[dict] = None system: Optional[str] = None template: Optional[str] = None @@ -1482,7 +1503,9 @@ async def download_file_stream( timeout = aiohttp.ClientTimeout(total=600) # Set the timeout async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: - async with session.get(file_url, headers=headers) as response: + async with session.get( + file_url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as response: total_size = int(response.headers.get("content-length", 0)) + current_size with open(file_path, "ab+") as file: @@ -1497,7 +1520,8 @@ async def download_file_stream( if done: file.seek(0) - hashed = calculate_sha256(file) + chunk_size = 1024 * 1024 * 2 + hashed = calculate_sha256(file, chunk_size) file.seek(0) url = f"{ollama_url}/api/blobs/sha256:{hashed}" diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 0310014cf5..02a81209c1 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -21,6 +21,7 @@ from open_webui.config import ( CACHE_DIR, ) from open_webui.env import ( + AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, ENABLE_FORWARD_USER_INFO_HEADERS, @@ -74,6 +75,7 @@ async def send_get_request(url, key=None, user: UserModel = None): else {} ), }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: return await response.json() except Exception as e: @@ -92,20 +94,19 @@ async def cleanup_response( await session.close() -def openai_o1_o3_handler(payload): +def openai_o_series_handler(payload): """ - Handle o1, o3 specific parameters + Handle "o" series specific parameters """ if "max_tokens" in payload: - # Remove "max_tokens" from the payload + # Convert "max_tokens" to "max_completion_tokens" for all o-series models payload["max_completion_tokens"] = payload["max_tokens"] del payload["max_tokens"] - # Fix: o1 and o3 do not support the "system" role directly. - # For older models like "o1-mini" or "o1-preview", use role "user". - # For newer o1/o3 models, replace "system" with "developer". + # Handle system role conversion based on model type if payload["messages"][0]["role"] == "system": model_lower = payload["model"].lower() + # Legacy models use "user" role instead of "system" if model_lower.startswith("o1-mini") or model_lower.startswith("o1-preview"): payload["messages"][0]["role"] = "user" else: @@ -462,7 +463,8 @@ async def get_models( r = None async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), ) as session: try: async with session.get( @@ -481,6 +483,7 @@ async def get_models( else {} ), }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: if r.status != 200: # Extract response error details if available @@ -542,7 +545,8 @@ async def verify_connection( key = form_data.key async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), ) as session: try: async with session.get( @@ -561,6 +565,7 @@ async def verify_connection( else {} ), }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: if r.status != 200: # Extract response error details if available @@ -666,10 +671,10 @@ async def generate_chat_completion( url = request.app.state.config.OPENAI_API_BASE_URLS[idx] key = request.app.state.config.OPENAI_API_KEYS[idx] - # Fix: o1,o3 does not support the "max_tokens" parameter, Modify "max_tokens" to "max_completion_tokens" - is_o1_o3 = payload["model"].lower().startswith(("o1", "o3-")) - if is_o1_o3: - payload = openai_o1_o3_handler(payload) + # Check if model is from "o" series + is_o_series = payload["model"].lower().startswith(("o1", "o3", "o4")) + if is_o_series: + payload = openai_o_series_handler(payload) elif "api.openai.com" not in url: # Remove "max_completion_tokens" from the payload for backward compatibility if "max_completion_tokens" in payload: @@ -723,6 +728,7 @@ async def generate_chat_completion( else {} ), }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) # Check if response is SSE @@ -802,6 +808,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): else {} ), }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) r.raise_for_status() diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index 10c8e9b2ec..f140025026 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -66,7 +66,7 @@ async def process_pipeline_inlet_filter(request, payload, user, models): if "pipeline" in model: sorted_filters.append(model) - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: for filter in sorted_filters: urlIdx = filter.get("urlIdx") if urlIdx is None: @@ -115,7 +115,7 @@ async def process_pipeline_outlet_filter(request, payload, user, models): if "pipeline" in model: sorted_filters = [model] + sorted_filters - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: for filter in sorted_filters: urlIdx = filter.get("urlIdx") if urlIdx is None: diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 13f012483d..efefa12fcd 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -3,6 +3,8 @@ import logging import mimetypes import os import shutil +import asyncio + import uuid from datetime import datetime @@ -53,6 +55,7 @@ from open_webui.retrieval.web.jina_search import search_jina from open_webui.retrieval.web.searchapi import search_searchapi from open_webui.retrieval.web.serpapi import search_serpapi from open_webui.retrieval.web.searxng import search_searxng +from open_webui.retrieval.web.yacy import search_yacy from open_webui.retrieval.web.serper import search_serper from open_webui.retrieval.web.serply import search_serply from open_webui.retrieval.web.serpstack import search_serpstack @@ -61,6 +64,8 @@ from open_webui.retrieval.web.bing import search_bing from open_webui.retrieval.web.exa import search_exa from open_webui.retrieval.web.perplexity import search_perplexity from open_webui.retrieval.web.sougou import search_sougou +from open_webui.retrieval.web.firecrawl import search_firecrawl +from open_webui.retrieval.web.external import search_external from open_webui.retrieval.utils import ( get_embedding_function, @@ -90,7 +95,12 @@ from open_webui.env import ( SRC_LOG_LEVELS, DEVICE_TYPE, DOCKER, + SENTENCE_TRANSFORMERS_BACKEND, + SENTENCE_TRANSFORMERS_MODEL_KWARGS, + SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND, + SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS, ) + from open_webui.constants import ERROR_MESSAGES log = logging.getLogger(__name__) @@ -117,6 +127,8 @@ def get_ef( get_model_path(embedding_model, auto_update), device=DEVICE_TYPE, trust_remote_code=RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE, + backend=SENTENCE_TRANSFORMERS_BACKEND, + model_kwargs=SENTENCE_TRANSFORMERS_MODEL_KWARGS, ) except Exception as e: log.debug(f"Error loading SentenceTransformer: {e}") @@ -125,7 +137,10 @@ def get_ef( def get_rf( + engine: str = "", reranking_model: Optional[str] = None, + external_reranker_url: str = "", + external_reranker_api_key: str = "", auto_update: bool = False, ): rf = None @@ -143,17 +158,33 @@ def get_rf( log.error(f"ColBERT: {e}") raise Exception(ERROR_MESSAGES.DEFAULT(e)) else: - import sentence_transformers + if engine == "external": + try: + from open_webui.retrieval.models.external import ExternalReranker + + rf = ExternalReranker( + url=external_reranker_url, + api_key=external_reranker_api_key, + model=reranking_model, + ) + except Exception as e: + log.error(f"ExternalReranking: {e}") + raise Exception(ERROR_MESSAGES.DEFAULT(e)) + else: + import sentence_transformers + + try: + rf = sentence_transformers.CrossEncoder( + get_model_path(reranking_model, auto_update), + device=DEVICE_TYPE, + trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, + backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND, + model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS, + ) + except Exception as e: + log.error(f"CrossEncoder: {e}") + raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error")) - try: - rf = sentence_transformers.CrossEncoder( - get_model_path(reranking_model, auto_update), - device=DEVICE_TYPE, - trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, - ) - except Exception as e: - log.error(f"CrossEncoder: {e}") - raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error")) return rf @@ -176,7 +207,7 @@ class ProcessUrlForm(CollectionNameForm): class SearchForm(BaseModel): - query: str + queries: List[str] @router.get("/") @@ -211,14 +242,6 @@ async def get_embedding_config(request: Request, user=Depends(get_admin_user)): } -@router.get("/reranking") -async def get_reraanking_config(request: Request, user=Depends(get_admin_user)): - return { - "status": True, - "reranking_model": request.app.state.config.RAG_RERANKING_MODEL, - } - - class OpenAIConfigForm(BaseModel): url: str key: str @@ -313,41 +336,6 @@ async def update_embedding_config( ) -class RerankingModelUpdateForm(BaseModel): - reranking_model: str - - -@router.post("/reranking/update") -async def update_reranking_config( - request: Request, form_data: RerankingModelUpdateForm, user=Depends(get_admin_user) -): - log.info( - f"Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.reranking_model}" - ) - try: - request.app.state.config.RAG_RERANKING_MODEL = form_data.reranking_model - - try: - request.app.state.rf = get_rf( - request.app.state.config.RAG_RERANKING_MODEL, - True, - ) - except Exception as e: - log.error(f"Error loading reranking model: {e}") - request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False - - return { - "status": True, - "reranking_model": request.app.state.config.RAG_RERANKING_MODEL, - } - except Exception as e: - log.exception(f"Problem updating reranking model: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=ERROR_MESSAGES.DEFAULT(e), - ) - - @router.get("/config") async def get_rag_config(request: Request, user=Depends(get_admin_user)): return { @@ -366,9 +354,16 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES, "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, + "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, + "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG, "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY, + # Reranking settings + "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, + "RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE, + "RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL, + "RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, # Chunking settings "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, @@ -389,6 +384,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL, + "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, + "YACY_USERNAME": request.app.state.config.YACY_USERNAME, + "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD, "GOOGLE_PSE_API_KEY": request.app.state.config.GOOGLE_PSE_API_KEY, "GOOGLE_PSE_ENGINE_ID": request.app.state.config.GOOGLE_PSE_ENGINE_ID, "BRAVE_SEARCH_API_KEY": request.app.state.config.BRAVE_SEARCH_API_KEY, @@ -418,6 +416,10 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY, "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL, "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH, + "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL, + "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, + "EXTERNAL_WEB_LOADER_URL": request.app.state.config.EXTERNAL_WEB_LOADER_URL, + "EXTERNAL_WEB_LOADER_API_KEY": request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY, "YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE, "YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL, "YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION, @@ -434,6 +436,9 @@ class WebConfig(BaseModel): WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = [] BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None SEARXNG_QUERY_URL: Optional[str] = None + YACY_QUERY_URL: Optional[str] = None + YACY_USERNAME: Optional[str] = None + YACY_PASSWORD: Optional[str] = None GOOGLE_PSE_API_KEY: Optional[str] = None GOOGLE_PSE_ENGINE_ID: Optional[str] = None BRAVE_SEARCH_API_KEY: Optional[str] = None @@ -463,6 +468,10 @@ class WebConfig(BaseModel): FIRECRAWL_API_KEY: Optional[str] = None FIRECRAWL_API_BASE_URL: Optional[str] = None TAVILY_EXTRACT_DEPTH: Optional[str] = None + EXTERNAL_WEB_SEARCH_URL: Optional[str] = None + EXTERNAL_WEB_SEARCH_API_KEY: Optional[str] = None + EXTERNAL_WEB_LOADER_URL: Optional[str] = None + EXTERNAL_WEB_LOADER_API_KEY: Optional[str] = None YOUTUBE_LOADER_LANGUAGE: Optional[List[str]] = None YOUTUBE_LOADER_PROXY_URL: Optional[str] = None YOUTUBE_LOADER_TRANSLATION: Optional[str] = None @@ -485,10 +494,18 @@ class ConfigForm(BaseModel): PDF_EXTRACT_IMAGES: Optional[bool] = None TIKA_SERVER_URL: Optional[str] = None DOCLING_SERVER_URL: Optional[str] = None + DOCLING_OCR_ENGINE: Optional[str] = None + DOCLING_OCR_LANG: Optional[str] = None DOCUMENT_INTELLIGENCE_ENDPOINT: Optional[str] = None DOCUMENT_INTELLIGENCE_KEY: Optional[str] = None MISTRAL_OCR_API_KEY: Optional[str] = None + # Reranking settings + RAG_RERANKING_MODEL: Optional[str] = None + RAG_RERANKING_ENGINE: Optional[str] = None + RAG_EXTERNAL_RERANKER_URL: Optional[str] = None + RAG_EXTERNAL_RERANKER_API_KEY: Optional[str] = None + # Chunking settings TEXT_SPLITTER: Optional[str] = None CHUNK_SIZE: Optional[int] = None @@ -574,6 +591,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_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.DOCUMENT_INTELLIGENCE_ENDPOINT = ( form_data.DOCUMENT_INTELLIGENCE_ENDPOINT if form_data.DOCUMENT_INTELLIGENCE_ENDPOINT is not None @@ -590,6 +617,49 @@ async def update_rag_config( else request.app.state.config.MISTRAL_OCR_API_KEY ) + # Reranking settings + request.app.state.config.RAG_RERANKING_ENGINE = ( + form_data.RAG_RERANKING_ENGINE + if form_data.RAG_RERANKING_ENGINE is not None + else request.app.state.config.RAG_RERANKING_ENGINE + ) + + request.app.state.config.RAG_EXTERNAL_RERANKER_URL = ( + form_data.RAG_EXTERNAL_RERANKER_URL + if form_data.RAG_EXTERNAL_RERANKER_URL is not None + else request.app.state.config.RAG_EXTERNAL_RERANKER_URL + ) + + request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = ( + form_data.RAG_EXTERNAL_RERANKER_API_KEY + if form_data.RAG_EXTERNAL_RERANKER_API_KEY is not None + else request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY + ) + + log.info( + f"Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}" + ) + try: + request.app.state.config.RAG_RERANKING_MODEL = form_data.RAG_RERANKING_MODEL + + try: + request.app.state.rf = get_rf( + request.app.state.config.RAG_RERANKING_ENGINE, + request.app.state.config.RAG_RERANKING_MODEL, + request.app.state.config.RAG_EXTERNAL_RERANKER_URL, + request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, + True, + ) + except Exception as e: + log.error(f"Error loading reranking model: {e}") + request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False + except Exception as e: + log.exception(f"Problem updating reranking model: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(e), + ) + # Chunking settings request.app.state.config.TEXT_SPLITTER = ( form_data.TEXT_SPLITTER @@ -651,6 +721,9 @@ async def update_rag_config( form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL ) request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL + request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL + request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME + request.app.state.config.YACY_PASSWORD = form_data.web.YACY_PASSWORD request.app.state.config.GOOGLE_PSE_API_KEY = form_data.web.GOOGLE_PSE_API_KEY request.app.state.config.GOOGLE_PSE_ENGINE_ID = ( form_data.web.GOOGLE_PSE_ENGINE_ID @@ -697,6 +770,18 @@ async def update_rag_config( request.app.state.config.FIRECRAWL_API_BASE_URL = ( form_data.web.FIRECRAWL_API_BASE_URL ) + request.app.state.config.EXTERNAL_WEB_SEARCH_URL = ( + form_data.web.EXTERNAL_WEB_SEARCH_URL + ) + request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = ( + form_data.web.EXTERNAL_WEB_SEARCH_API_KEY + ) + request.app.state.config.EXTERNAL_WEB_LOADER_URL = ( + form_data.web.EXTERNAL_WEB_LOADER_URL + ) + request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY = ( + form_data.web.EXTERNAL_WEB_LOADER_API_KEY + ) request.app.state.config.TAVILY_EXTRACT_DEPTH = ( form_data.web.TAVILY_EXTRACT_DEPTH ) @@ -726,9 +811,16 @@ async def update_rag_config( "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES, "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, + "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, + "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG, "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY, + # Reranking settings + "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, + "RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE, + "RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL, + "RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, # Chunking settings "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, @@ -749,6 +841,9 @@ async def update_rag_config( "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL, + "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, + "YACY_USERNAME": request.app.state.config.YACY_USERNAME, + "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD, "GOOGLE_PSE_API_KEY": request.app.state.config.GOOGLE_PSE_API_KEY, "GOOGLE_PSE_ENGINE_ID": request.app.state.config.GOOGLE_PSE_ENGINE_ID, "BRAVE_SEARCH_API_KEY": request.app.state.config.BRAVE_SEARCH_API_KEY, @@ -778,6 +873,10 @@ async def update_rag_config( "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY, "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL, "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH, + "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL, + "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, + "EXTERNAL_WEB_LOADER_URL": request.app.state.config.EXTERNAL_WEB_LOADER_URL, + "EXTERNAL_WEB_LOADER_API_KEY": request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY, "YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE, "YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL, "YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION, @@ -1032,6 +1131,8 @@ def process_file( engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE, TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL, DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL, + DOCLING_OCR_ENGINE=request.app.state.config.DOCLING_OCR_ENGINE, + DOCLING_OCR_LANG=request.app.state.config.DOCLING_OCR_LANG, 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, @@ -1266,6 +1367,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: """Search the web using a search engine and return the results as a list of SearchResult objects. Will look for a search engine API key in environment variables in the following order: - SEARXNG_QUERY_URL + - YACY_QUERY_URL + YACY_USERNAME + YACY_PASSWORD - GOOGLE_PSE_API_KEY + GOOGLE_PSE_ENGINE_ID - BRAVE_SEARCH_API_KEY - KAGI_SEARCH_API_KEY @@ -1295,6 +1397,18 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: ) else: raise Exception("No SEARXNG_QUERY_URL found in environment variables") + elif engine == "yacy": + if request.app.state.config.YACY_QUERY_URL: + return search_yacy( + request.app.state.config.YACY_QUERY_URL, + request.app.state.config.YACY_USERNAME, + request.app.state.config.YACY_PASSWORD, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) + else: + raise Exception("No YACY_QUERY_URL found in environment variables") elif engine == "google_pse": if ( request.app.state.config.GOOGLE_PSE_API_KEY @@ -1465,6 +1579,22 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: raise Exception( "No SOUGOU_API_SID or SOUGOU_API_SK found in environment variables" ) + elif engine == "firecrawl": + return search_firecrawl( + request.app.state.config.FIRECRAWL_API_BASE_URL, + request.app.state.config.FIRECRAWL_API_KEY, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) + elif engine == "external": + return search_external( + request.app.state.config.EXTERNAL_WEB_SEARCH_URL, + request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) else: raise Exception("No search engine API key found in environment variables") @@ -1473,13 +1603,34 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: async def process_web_search( request: Request, form_data: SearchForm, user=Depends(get_verified_user) ): + + urls = [] try: logging.info( - f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.query}" - ) - web_results = search_web( - request, request.app.state.config.WEB_SEARCH_ENGINE, form_data.query + f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}" ) + + search_tasks = [ + run_in_threadpool( + search_web, + request, + request.app.state.config.WEB_SEARCH_ENGINE, + query, + ) + for query in form_data.queries + ] + + search_results = await asyncio.gather(*search_tasks) + + for result in search_results: + if result: + for item in result: + if item and item.link: + urls.append(item.link) + + urls = list(dict.fromkeys(urls)) + log.debug(f"urls: {urls}") + except Exception as e: log.exception(e) @@ -1488,10 +1639,7 @@ async def process_web_search( detail=ERROR_MESSAGES.WEB_SEARCH_ERROR(e), ) - log.debug(f"web_results: {web_results}") - try: - urls = [result.link for result in web_results] loader = get_web_loader( urls, verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, @@ -1500,8 +1648,8 @@ async def process_web_search( ) docs = await loader.aload() urls = [ - doc.metadata["source"] for doc in docs - ] # only keep URLs which could be retrieved + doc.metadata.get("source") for doc in docs if doc.metadata.get("source") + ] # only keep the urls returned by the loader if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: return { @@ -1518,26 +1666,28 @@ async def process_web_search( "loaded_count": len(docs), } else: - collection_names = [] - for doc_idx, doc in enumerate(docs): - if doc and doc.page_content: - collection_name = f"web-search-{calculate_sha256_string(form_data.query + '-' + urls[doc_idx])}"[ - :63 - ] + # Create a single collection for all documents + collection_name = ( + f"web-search-{calculate_sha256_string('-'.join(form_data.queries))}"[ + :63 + ] + ) - collection_names.append(collection_name) - await run_in_threadpool( - save_docs_to_vector_db, - request, - [doc], - collection_name, - overwrite=True, - user=user, - ) + try: + await run_in_threadpool( + save_docs_to_vector_db, + request, + docs, + collection_name, + overwrite=True, + user=user, + ) + except Exception as e: + log.debug(f"error saving docs: {e}") return { "status": True, - "collection_names": collection_names, + "collection_names": [collection_name], "filenames": urls, "loaded_count": len(docs), } diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 39fca43d3e..14a6c42860 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -186,20 +186,9 @@ async def generate_title( else: template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE - messages = form_data["messages"] - - # Remove reasoning details from the messages - for message in messages: - message["content"] = re.sub( - r"]*>.*?<\/details>", - "", - message["content"], - flags=re.S, - ).strip() - content = title_generation_template( template, - messages, + form_data["messages"], { "name": user.name, "location": user.info.get("location") if user.info else None, diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index a9ac34e2fb..8702ae50ba 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -6,6 +6,7 @@ from open_webui.models.groups import Groups from open_webui.models.chats import Chats from open_webui.models.users import ( UserModel, + UserListResponse, UserRoleUpdateForm, Users, UserSettings, @@ -20,7 +21,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user -from open_webui.utils.access_control import get_permissions +from open_webui.utils.access_control import get_permissions, has_permission log = logging.getLogger(__name__) @@ -33,13 +34,38 @@ router = APIRouter() ############################ -@router.get("/", response_model=list[UserModel]) +PAGE_ITEM_COUNT = 30 + + +@router.get("/", response_model=UserListResponse) async def get_users( - skip: Optional[int] = None, - limit: Optional[int] = None, + query: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, + page: Optional[int] = 1, user=Depends(get_admin_user), ): - return Users.get_users(skip, limit) + limit = PAGE_ITEM_COUNT + + page = max(1, page) + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + + return Users.get_users(filter=filter, skip=skip, limit=limit) + + +@router.get("/all", response_model=UserListResponse) +async def get_all_users( + user=Depends(get_admin_user), +): + return Users.get_users() ############################ @@ -88,6 +114,8 @@ class ChatPermissions(BaseModel): file_upload: bool = True delete: bool = True edit: bool = True + share: bool = True + export: bool = True stt: bool = True tts: bool = True call: bool = True @@ -101,6 +129,7 @@ class FeaturesPermissions(BaseModel): web_search: bool = True image_generation: bool = True code_interpreter: bool = True + notes: bool = True class UserPermissions(BaseModel): @@ -176,9 +205,22 @@ async def get_user_settings_by_session_user(user=Depends(get_verified_user)): @router.post("/user/settings/update", response_model=UserSettings) async def update_user_settings_by_session_user( - form_data: UserSettings, user=Depends(get_verified_user) + request: Request, form_data: UserSettings, user=Depends(get_verified_user) ): - user = Users.update_user_settings_by_id(user.id, form_data.model_dump()) + updated_user_settings = form_data.model_dump() + if ( + user.role != "admin" + and "toolServers" in updated_user_settings.get("ui").keys() + and not has_permission( + user.id, + "features.direct_tool_servers", + request.app.state.config.USER_PERMISSIONS, + ) + ): + # If the user is not an admin and does not have permission to use tool servers, remove the key + updated_user_settings["ui"].pop("toolServers", None) + + user = Users.update_user_settings_by_id(user.id, updated_user_settings) if user: return user.settings else: @@ -288,6 +330,21 @@ async def update_user_by_id( form_data: UserUpdateForm, session_user=Depends(get_admin_user), ): + # Prevent modification of the primary admin user by other admins + try: + first_user = Users.get_first_user() + if first_user and user_id == first_user.id and session_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACTION_PROHIBITED, + ) + except Exception as e: + log.error(f"Error checking primary admin status: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not verify primary admin status.", + ) + user = Users.get_user_by_id(user_id) if user: @@ -335,6 +392,21 @@ async def update_user_by_id( @router.delete("/{user_id}", response_model=bool) async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)): + # Prevent deletion of the primary admin user + try: + first_user = Users.get_first_user() + if first_user and user_id == first_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACTION_PROHIBITED, + ) + except Exception as e: + log.error(f"Error checking primary admin status: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not verify primary admin status.", + ) + if user.id != user_id: result = Auths.delete_auth_by_id(user_id) @@ -346,6 +418,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)): detail=ERROR_MESSAGES.DELETE_USER_ERROR, ) + # Prevent self-deletion raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED, diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 282f4db956..09eccd8267 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -159,18 +159,19 @@ def get_models_in_use(): @sio.on("usage") async def usage(sid, data): - model_id = data["model"] - # Record the timestamp for the last update - current_time = int(time.time()) + if sid in SESSION_POOL: + model_id = data["model"] + # Record the timestamp for the last update + current_time = int(time.time()) - # Store the new usage data and task - USAGE_POOL[model_id] = { - **(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}), - sid: {"updated_at": current_time}, - } + # Store the new usage data and task + USAGE_POOL[model_id] = { + **(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}), + sid: {"updated_at": current_time}, + } - # Broadcast the usage data to all clients - await sio.emit("usage", {"models": get_models_in_use()}) + # Broadcast the usage data to all clients + await sio.emit("usage", {"models": get_models_in_use()}) @sio.event @@ -278,7 +279,8 @@ async def channel_events(sid, data): @sio.on("user-list") async def user_list(sid): - await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())}) + if sid in SESSION_POOL: + await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())}) @sio.event @@ -314,8 +316,8 @@ def get_event_emitter(request_info, update_db=True): ) ) - for session_id in session_ids: - await sio.emit( + emit_tasks = [ + sio.emit( "chat-events", { "chat_id": request_info.get("chat_id", None), @@ -324,6 +326,10 @@ def get_event_emitter(request_info, update_db=True): }, to=session_id, ) + for session_id in session_ids + ] + + await asyncio.gather(*emit_tasks) if update_db: if "type" in event_data and event_data["type"] == "status": diff --git a/backend/open_webui/storage/provider.py b/backend/open_webui/storage/provider.py index c5c0056cc4..5c85f88bce 100644 --- a/backend/open_webui/storage/provider.py +++ b/backend/open_webui/storage/provider.py @@ -3,7 +3,7 @@ import shutil import json import logging from abc import ABC, abstractmethod -from typing import BinaryIO, Tuple +from typing import BinaryIO, Tuple, Dict import boto3 from botocore.config import Config @@ -17,6 +17,7 @@ from open_webui.config import ( S3_SECRET_ACCESS_KEY, S3_USE_ACCELERATE_ENDPOINT, S3_ADDRESSING_STYLE, + S3_ENABLE_TAGGING, GCS_BUCKET_NAME, GOOGLE_APPLICATION_CREDENTIALS_JSON, AZURE_STORAGE_ENDPOINT, @@ -44,7 +45,9 @@ class StorageProvider(ABC): pass @abstractmethod - def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]: + def upload_file( + self, file: BinaryIO, filename: str, tags: Dict[str, str] + ) -> Tuple[bytes, str]: pass @abstractmethod @@ -58,7 +61,9 @@ class StorageProvider(ABC): class LocalStorageProvider(StorageProvider): @staticmethod - def upload_file(file: BinaryIO, filename: str) -> Tuple[bytes, str]: + def upload_file( + file: BinaryIO, filename: str, tags: Dict[str, str] + ) -> Tuple[bytes, str]: contents = file.read() if not contents: raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT) @@ -131,15 +136,24 @@ class S3StorageProvider(StorageProvider): self.bucket_name = S3_BUCKET_NAME self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else "" - def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]: + def upload_file( + self, file: BinaryIO, filename: str, tags: Dict[str, str] + ) -> Tuple[bytes, str]: """Handles uploading of the file to S3 storage.""" - _, file_path = LocalStorageProvider.upload_file(file, filename) + _, file_path = LocalStorageProvider.upload_file(file, filename, tags) + s3_key = os.path.join(self.key_prefix, filename) try: - s3_key = os.path.join(self.key_prefix, filename) self.s3_client.upload_file(file_path, self.bucket_name, s3_key) + if S3_ENABLE_TAGGING and tags: + tagging = {"TagSet": [{"Key": k, "Value": v} for k, v in tags.items()]} + self.s3_client.put_object_tagging( + Bucket=self.bucket_name, + Key=s3_key, + Tagging=tagging, + ) return ( open(file_path, "rb").read(), - "s3://" + self.bucket_name + "/" + s3_key, + f"s3://{self.bucket_name}/{s3_key}", ) except ClientError as e: raise RuntimeError(f"Error uploading file to S3: {e}") @@ -207,9 +221,11 @@ class GCSStorageProvider(StorageProvider): self.gcs_client = storage.Client() self.bucket = self.gcs_client.bucket(GCS_BUCKET_NAME) - def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]: + def upload_file( + self, file: BinaryIO, filename: str, tags: Dict[str, str] + ) -> Tuple[bytes, str]: """Handles uploading of the file to GCS storage.""" - contents, file_path = LocalStorageProvider.upload_file(file, filename) + contents, file_path = LocalStorageProvider.upload_file(file, filename, tags) try: blob = self.bucket.blob(filename) blob.upload_from_filename(file_path) @@ -277,9 +293,11 @@ class AzureStorageProvider(StorageProvider): self.container_name ) - def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]: + def upload_file( + self, file: BinaryIO, filename: str, tags: Dict[str, str] + ) -> Tuple[bytes, str]: """Handles uploading of the file to Azure Blob Storage.""" - contents, file_path = LocalStorageProvider.upload_file(file, filename) + contents, file_path = LocalStorageProvider.upload_file(file, filename, tags) try: blob_client = self.container_client.get_blob_client(filename) blob_client.upload_blob(contents, overwrite=True) diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index 2d7ceabcb8..8193907d27 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -37,7 +37,7 @@ if TYPE_CHECKING: class AuditLogEntry: # `Metadata` audit level properties id: str - user: dict[str, Any] + user: Optional[dict[str, Any]] audit_level: str verb: str request_uri: str @@ -190,21 +190,40 @@ class AuditLoggingMiddleware: finally: await self._log_audit_entry(request, context) - async def _get_authenticated_user(self, request: Request) -> UserModel: - + async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]: auth_header = request.headers.get("Authorization") - assert auth_header - user = get_current_user(request, None, get_http_authorization_cred(auth_header)) - return user + try: + user = get_current_user( + request, None, get_http_authorization_cred(auth_header) + ) + return user + except Exception as e: + logger.debug(f"Failed to get authenticated user: {str(e)}") + + return None def _should_skip_auditing(self, request: Request) -> bool: if ( request.method not in {"POST", "PUT", "PATCH", "DELETE"} or AUDIT_LOG_LEVEL == "NONE" - or not request.headers.get("authorization") ): return True + + ALWAYS_LOG_ENDPOINTS = { + "/api/v1/auths/signin", + "/api/v1/auths/signout", + "/api/v1/auths/signup", + } + path = request.url.path.lower() + for endpoint in ALWAYS_LOG_ENDPOINTS: + if path.startswith(endpoint): + return False # Do NOT skip logging for auth endpoints + + # Skip logging if the request is not authenticated + if not request.headers.get("authorization"): + return True + # match either /api//...(for the endpoint /api/chat case) or /api/v1//... pattern = re.compile( r"^/api(?:/v1)?/(" + "|".join(self.excluded_paths) + r")\b" @@ -231,17 +250,32 @@ class AuditLoggingMiddleware: try: user = await self._get_authenticated_user(request) + user = ( + user.model_dump(include={"id", "name", "email", "role"}) if user else {} + ) + + request_body = context.request_body.decode("utf-8", errors="replace") + response_body = context.response_body.decode("utf-8", errors="replace") + + # Redact sensitive information + if "password" in request_body: + request_body = re.sub( + r'"password":\s*"(.*?)"', + '"password": "********"', + request_body, + ) + entry = AuditLogEntry( id=str(uuid.uuid4()), - user=user.model_dump(include={"id", "name", "email", "role"}), + user=user, audit_level=self.audit_level.value, verb=request.method, request_uri=str(request.url), response_status_code=context.metadata.get("response_status_code", None), source_ip=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), - request_object=context.request_body.decode("utf-8", errors="replace"), - response_object=context.response_body.decode("utf-8", errors="replace"), + request_object=request_body, + response_object=response_body, ) self.audit_logger.write(entry) diff --git a/backend/open_webui/utils/code_interpreter.py b/backend/open_webui/utils/code_interpreter.py index 312baff241..f3dcbb81fb 100644 --- a/backend/open_webui/utils/code_interpreter.py +++ b/backend/open_webui/utils/code_interpreter.py @@ -44,13 +44,15 @@ class JupyterCodeExecuter: :param password: Jupyter password (optional) :param timeout: WebSocket timeout in seconds (default: 60s) """ - self.base_url = base_url.rstrip("/") + self.base_url = base_url self.code = code self.token = token self.password = password self.timeout = timeout self.kernel_id = "" - self.session = aiohttp.ClientSession(base_url=self.base_url) + if self.base_url[-1] != "/": + self.base_url += "/" + self.session = aiohttp.ClientSession(trust_env=True, base_url=self.base_url) self.params = {} self.result = ResultModel() @@ -61,7 +63,7 @@ class JupyterCodeExecuter: if self.kernel_id: try: async with self.session.delete( - f"/api/kernels/{self.kernel_id}", params=self.params + f"api/kernels/{self.kernel_id}", params=self.params ) as response: response.raise_for_status() except Exception as err: @@ -81,7 +83,7 @@ class JupyterCodeExecuter: async def sign_in(self) -> None: # password authentication if self.password and not self.token: - async with self.session.get("/login") as response: + async with self.session.get("login") as response: response.raise_for_status() xsrf_token = response.cookies["_xsrf"].value if not xsrf_token: @@ -89,7 +91,7 @@ class JupyterCodeExecuter: self.session.cookie_jar.update_cookies(response.cookies) self.session.headers.update({"X-XSRFToken": xsrf_token}) async with self.session.post( - "/login", + "login", data={"_xsrf": xsrf_token, "password": self.password}, allow_redirects=False, ) as response: @@ -101,17 +103,15 @@ class JupyterCodeExecuter: self.params.update({"token": self.token}) async def init_kernel(self) -> None: - async with self.session.post( - url="/api/kernels", params=self.params - ) as response: + async with self.session.post(url="api/kernels", params=self.params) as response: response.raise_for_status() kernel_data = await response.json() self.kernel_id = kernel_data["id"] def init_ws(self) -> (str, dict): - ws_base = self.base_url.replace("http", "ws") + ws_base = self.base_url.replace("http", "ws", 1) ws_params = "?" + "&".join([f"{key}={val}" for key, val in self.params.items()]) - websocket_url = f"{ws_base}/api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ''}" + websocket_url = f"{ws_base}api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ''}" ws_headers = {} if self.password and not self.token: ws_headers = { diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 4070bc697f..442dfba76f 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -353,115 +353,86 @@ async def chat_web_search_handler( ) return form_data - all_results = [] + await event_emitter( + { + "type": "status", + "data": { + "action": "web_search", + "description": "Searching the web", + "done": False, + }, + } + ) - for searchQuery in queries: - await event_emitter( - { - "type": "status", - "data": { - "action": "web_search", - "description": 'Searching "{{searchQuery}}"', - "query": searchQuery, - "done": False, - }, - } + try: + results = await process_web_search( + request, + SearchForm(queries=queries), + user=user, ) - try: - results = await process_web_search( - request, - SearchForm( - **{ - "query": searchQuery, + if results: + files = form_data.get("files", []) + + if results.get("collection_names"): + for col_idx, collection_name in enumerate( + results.get("collection_names") + ): + files.append( + { + "collection_name": collection_name, + "name": ", ".join(queries), + "type": "web_search", + "urls": results["filenames"], + } + ) + elif results.get("docs"): + # Invoked when bypass embedding and retrieval is set to True + docs = results["docs"] + files.append( + { + "docs": docs, + "name": ", ".join(queries), + "type": "web_search", + "urls": results["filenames"], } - ), - user=user, - ) + ) - if results: - all_results.append(results) - files = form_data.get("files", []) + form_data["files"] = files - if results.get("collection_names"): - for col_idx, collection_name in enumerate( - results.get("collection_names") - ): - files.append( - { - "collection_name": collection_name, - "name": searchQuery, - "type": "web_search", - "urls": [results["filenames"][col_idx]], - } - ) - elif results.get("docs"): - # Invoked when bypass embedding and retrieval is set to True - docs = results["docs"] - - if len(docs) == len(results["filenames"]): - # the number of docs and filenames (urls) should be the same - for doc_idx, doc in enumerate(docs): - files.append( - { - "docs": [doc], - "name": searchQuery, - "type": "web_search", - "urls": [results["filenames"][doc_idx]], - } - ) - else: - # edge case when the number of docs and filenames (urls) are not the same - # this should not happen, but if it does, we will just append the docs - files.append( - { - "docs": results.get("docs", []), - "name": searchQuery, - "type": "web_search", - "urls": results["filenames"], - } - ) - - form_data["files"] = files - except Exception as e: - log.exception(e) await event_emitter( { "type": "status", "data": { "action": "web_search", - "description": 'Error searching "{{searchQuery}}"', - "query": searchQuery, + "description": "Searched {{count}} sites", + "urls": results["filenames"], + "done": True, + }, + } + ) + else: + await event_emitter( + { + "type": "status", + "data": { + "action": "web_search", + "description": "No search results found", "done": True, "error": True, }, } ) - if all_results: - urls = [] - for results in all_results: - if "filenames" in results: - urls.extend(results["filenames"]) - + except Exception as e: + log.exception(e) await event_emitter( { "type": "status", "data": { "action": "web_search", - "description": "Searched {{count}} sites", - "urls": urls, - "done": True, - }, - } - ) - else: - await event_emitter( - { - "type": "status", - "data": { - "action": "web_search", - "description": "No search results found", + "description": "An error occurred while searching the web", + "queries": queries, "done": True, "error": True, }, @@ -668,6 +639,9 @@ def apply_params_to_form_data(form_data, model): if "frequency_penalty" in params and params["frequency_penalty"] is not None: form_data["frequency_penalty"] = params["frequency_penalty"] + if "presence_penalty" in params and params["presence_penalty"] is not None: + form_data["presence_penalty"] = params["presence_penalty"] + if "reasoning_effort" in params and params["reasoning_effort"] is not None: form_data["reasoning_effort"] = params["reasoning_effort"] @@ -888,16 +862,20 @@ async def process_chat_payload(request, form_data, user, metadata, model): # If context is not empty, insert it into the messages if len(sources) > 0: context_string = "" - citated_file_idx = {} - for _, source in enumerate(sources, 1): + citation_idx = {} + for source in sources: if "document" in source: for doc_context, doc_meta in zip( source["document"], source["metadata"] ): - file_id = doc_meta.get("file_id") - if file_id not in citated_file_idx: - citated_file_idx[file_id] = len(citated_file_idx) + 1 - context_string += f'{doc_context}\n' + citation_id = ( + doc_meta.get("source", None) + or source.get("source", {}).get("id", None) + or "N/A" + ) + if citation_id not in citation_idx: + citation_idx[citation_id] = len(citation_idx) + 1 + context_string += f'{doc_context}\n' context_string = context_string.strip() prompt = get_last_user_message(form_data["messages"]) @@ -930,7 +908,12 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) # If there are citations, add them to the data_items - sources = [source for source in sources if source.get("source", {}).get("name", "")] + sources = [ + source + for source in sources + if source.get("source", {}).get("name", "") + or source.get("source", {}).get("id", "") + ] if len(sources) > 0: events.append({"sources": sources}) @@ -961,6 +944,20 @@ async def process_chat_response( if message: messages = get_message_list(message_map, message.get("id")) + # Remove reasoning details and files from the messages. + # as get_message_list creates a new list, it does not affect + # the original messages outside of this handler + for message in messages: + message["content"] = re.sub( + r"]*>.*?<\/details>", + "", + message["content"], + flags=re.S, + ).strip() + + if message.get("files"): + message["files"] = [] + if tasks and messages: if TASKS.TITLE_GENERATION in tasks: if tasks[TASKS.TITLE_GENERATION]: @@ -1129,7 +1126,7 @@ async def process_chat_response( ) # Send a webhook notification if the user is not active - if get_active_status_by_user_id(user.id) is None: + if not get_active_status_by_user_id(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: post_webhook( @@ -1417,6 +1414,9 @@ async def process_chat_response( if after_tag: content_blocks[-1]["content"] = after_tag + tag_content_handler( + content_type, tags, after_tag, content_blocks + ) break elif content_blocks[-1]["type"] == content_type: @@ -1667,6 +1667,15 @@ async def process_chat_response( if current_response_tool_call is None: # Add the new tool call + delta_tool_call.setdefault( + "function", {} + ) + delta_tool_call[ + "function" + ].setdefault("name", "") + delta_tool_call[ + "function" + ].setdefault("arguments", "") response_tool_calls.append( delta_tool_call ) @@ -2211,7 +2220,7 @@ async def process_chat_response( ) # Send a webhook notification if the user is not active - if get_active_status_by_user_id(user.id) is None: + if not get_active_status_by_user_id(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: post_webhook( diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 95d360bed8..245eaf874c 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -203,6 +203,7 @@ async def get_all_models(request, user: UserModel = None): else: function_module, _, _ = load_function_module_by_id(function_id) request.app.state.FUNCTIONS[function_id] = function_module + return function_module for model in models: action_ids = [ diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 9ebe0e6dcb..0bd82b577d 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -3,6 +3,7 @@ import logging import mimetypes import sys import uuid +import json import aiohttp from authlib.integrations.starlette_client import OAuth @@ -15,7 +16,7 @@ from starlette.responses import RedirectResponse from open_webui.models.auths import Auths from open_webui.models.users import Users -from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm +from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm from open_webui.config import ( DEFAULT_USER_ROLE, ENABLE_OAUTH_SIGNUP, @@ -23,6 +24,8 @@ from open_webui.config import ( OAUTH_PROVIDERS, ENABLE_OAUTH_ROLE_MANAGEMENT, ENABLE_OAUTH_GROUP_MANAGEMENT, + ENABLE_OAUTH_GROUP_CREATION, + OAUTH_BLOCKED_GROUPS, OAUTH_ROLES_CLAIM, OAUTH_GROUPS_CLAIM, OAUTH_EMAIL_CLAIM, @@ -31,6 +34,7 @@ from open_webui.config import ( OAUTH_ALLOWED_ROLES, OAUTH_ADMIN_ROLES, OAUTH_ALLOWED_DOMAINS, + OAUTH_UPDATE_PICTURE_ON_LOGIN, WEBHOOK_URL, JWT_EXPIRES_IN, AppConfig, @@ -57,6 +61,8 @@ auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT +auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION +auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM @@ -67,6 +73,7 @@ auth_manager_config.OAUTH_ADMIN_ROLES = OAUTH_ADMIN_ROLES auth_manager_config.OAUTH_ALLOWED_DOMAINS = OAUTH_ALLOWED_DOMAINS auth_manager_config.WEBHOOK_URL = WEBHOOK_URL auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN +auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN class OAuthManager: @@ -140,6 +147,12 @@ class OAuthManager: log.debug("Running OAUTH Group management") oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM + try: + blocked_groups = json.loads(auth_manager_config.OAUTH_BLOCKED_GROUPS) + except Exception as e: + log.exception(f"Error loading OAUTH_BLOCKED_GROUPS: {e}") + blocked_groups = [] + user_oauth_groups = [] # Nested claim search for groups claim if oauth_claim: @@ -147,11 +160,62 @@ class OAuthManager: nested_claims = oauth_claim.split(".") for nested_claim in nested_claims: claim_data = claim_data.get(nested_claim, {}) - user_oauth_groups = claim_data if isinstance(claim_data, list) else [] + + if isinstance(claim_data, list): + user_oauth_groups = claim_data + elif isinstance(claim_data, str): + user_oauth_groups = [claim_data] + else: + user_oauth_groups = [] user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id) all_available_groups: list[GroupModel] = Groups.get_groups() + # Create groups if they don't exist and creation is enabled + if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION: + log.debug("Checking for missing groups to create...") + all_group_names = {g.name for g in all_available_groups} + groups_created = False + # Determine creator ID: Prefer admin, fallback to current user if no admin exists + admin_user = Users.get_super_admin_user() + creator_id = admin_user.id if admin_user else user.id + log.debug(f"Using creator ID {creator_id} for potential group creation.") + + for group_name in user_oauth_groups: + if group_name not in all_group_names: + log.info( + f"Group '{group_name}' not found via OAuth claim. Creating group..." + ) + try: + new_group_form = GroupForm( + name=group_name, + description=f"Group '{group_name}' created automatically via OAuth.", + permissions=default_permissions, # Use default permissions from function args + user_ids=[], # Start with no users, user will be added later by subsequent logic + ) + # Use determined creator ID (admin or fallback to current user) + created_group = Groups.insert_new_group( + creator_id, new_group_form + ) + if created_group: + log.info( + f"Successfully created group '{group_name}' with ID {created_group.id} using creator ID {creator_id}" + ) + groups_created = True + # Add to local set to prevent duplicate creation attempts in this run + all_group_names.add(group_name) + else: + log.error( + f"Failed to create group '{group_name}' via OAuth." + ) + except Exception as e: + log.error(f"Error creating group '{group_name}' via OAuth: {e}") + + # Refresh the list of all available groups if any were created + if groups_created: + all_available_groups = Groups.get_groups() + log.debug("Refreshed list of all available groups after creation.") + log.debug(f"Oauth Groups claim: {oauth_claim}") log.debug(f"User oauth groups: {user_oauth_groups}") log.debug(f"User's current groups: {[g.name for g in user_current_groups]}") @@ -161,7 +225,11 @@ class OAuthManager: # Remove groups that user is no longer a part of for group_model in user_current_groups: - if user_oauth_groups and group_model.name not in user_oauth_groups: + if ( + user_oauth_groups + and group_model.name not in user_oauth_groups + and group_model.name not in blocked_groups + ): # Remove group from user log.debug( f"Removing user from group {group_model.name} as it is no longer in their oauth groups" @@ -191,6 +259,7 @@ class OAuthManager: user_oauth_groups and group_model.name in user_oauth_groups and not any(gm.name == group_model.name for gm in user_current_groups) + and group_model.name not in blocked_groups ): # Add user to group log.debug( @@ -215,6 +284,49 @@ class OAuthManager: id=group_model.id, form_data=update_form, overwrite=False ) + async def _process_picture_url( + self, picture_url: str, access_token: str = None + ) -> str: + """Process a picture URL and return a base64 encoded data URL. + + Args: + picture_url: The URL of the picture to process + access_token: Optional OAuth access token for authenticated requests + + Returns: + A data URL containing the base64 encoded picture, or "/user.png" if processing fails + """ + if not picture_url: + return "/user.png" + + try: + get_kwargs = {} + if access_token: + get_kwargs["headers"] = { + "Authorization": f"Bearer {access_token}", + } + async with aiohttp.ClientSession() as session: + async with session.get(picture_url, **get_kwargs) as resp: + if resp.ok: + picture = await resp.read() + base64_encoded_picture = base64.b64encode(picture).decode( + "utf-8" + ) + guessed_mime_type = mimetypes.guess_type(picture_url)[0] + if guessed_mime_type is None: + guessed_mime_type = "image/jpeg" + return ( + f"data:{guessed_mime_type};base64,{base64_encoded_picture}" + ) + else: + log.warning( + f"Failed to fetch profile picture from {picture_url}" + ) + return "/user.png" + except Exception as e: + log.error(f"Error processing profile picture '{picture_url}': {e}") + return "/user.png" + async def handle_login(self, request, provider): if provider not in OAUTH_PROVIDERS: raise HTTPException(404) @@ -257,7 +369,7 @@ class OAuthManager: try: access_token = token.get("access_token") headers = {"Authorization": f"Bearer {access_token}"} - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( "https://api.github.com/user/emails", headers=headers ) as resp: @@ -315,6 +427,22 @@ class OAuthManager: if user.role != determined_role: Users.update_user_role_by_id(user.id, determined_role) + # Update profile picture if enabled and different from current + if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: + picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM + if picture_claim: + new_picture_url = user_data.get( + picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "") + ) + processed_picture_url = await self._process_picture_url( + new_picture_url, token.get("access_token") + ) + if processed_picture_url != user.profile_image_url: + Users.update_user_profile_image_url_by_id( + user.id, processed_picture_url + ) + log.debug(f"Updated profile picture for user {user.email}") + if not user: user_count = Users.get_num_users() @@ -330,40 +458,9 @@ class OAuthManager: picture_url = user_data.get( picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "") ) - if picture_url: - # Download the profile image into a base64 string - try: - access_token = token.get("access_token") - get_kwargs = {} - if access_token: - get_kwargs["headers"] = { - "Authorization": f"Bearer {access_token}", - } - async with aiohttp.ClientSession() as session: - async with session.get( - picture_url, **get_kwargs - ) as resp: - if resp.ok: - picture = await resp.read() - base64_encoded_picture = base64.b64encode( - picture - ).decode("utf-8") - guessed_mime_type = mimetypes.guess_type( - picture_url - )[0] - if guessed_mime_type is None: - # assume JPG, browsers are tolerant enough of image formats - guessed_mime_type = "image/jpeg" - picture_url = f"data:{guessed_mime_type};base64,{base64_encoded_picture}" - else: - picture_url = "/user.png" - except Exception as e: - log.error( - f"Error downloading profile image '{picture_url}': {e}" - ) - picture_url = "/user.png" - if not picture_url: - picture_url = "/user.png" + picture_url = await self._process_picture_url( + picture_url, token.get("access_token") + ) else: picture_url = "/user.png" diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 5f8aafb785..d43dfd7890 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -59,6 +59,7 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict: "top_p": float, "max_tokens": int, "frequency_penalty": float, + "presence_penalty": float, "reasoning_effort": str, "seed": lambda x: x, "stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x], diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index d4e519601c..9c2ee1bbd1 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -157,7 +157,8 @@ def load_function_module_by_id(function_id, content=None): raise Exception("No Function class found in the module") except Exception as e: log.error(f"Error loading module: {function_id}: {e}") - del sys.modules[module_name] # Cleanup by removing the module in case of error + # Cleanup by removing the module in case of error + del sys.modules[module_name] Functions.update_function_by_id(function_id, {"is_active": False}) raise e @@ -182,3 +183,32 @@ def install_frontmatter_requirements(requirements: str): else: log.info("No requirements found in frontmatter.") + + +def install_tool_and_function_dependencies(): + """ + Install all dependencies for all admin tools and active functions. + + By first collecting all dependencies from the frontmatter of each tool and function, + and then installing them using pip. Duplicates or similar version specifications are + handled by pip as much as possible. + """ + function_list = Functions.get_functions(active_only=True) + tool_list = Tools.get_tools() + + all_dependencies = "" + try: + for function in function_list: + frontmatter = extract_frontmatter(replace_imports(function.content)) + if dependencies := frontmatter.get("requirements"): + all_dependencies += f"{dependencies}, " + for tool in tool_list: + # Only install requirements for admin tools + if tool.user.role == "admin": + frontmatter = extract_frontmatter(replace_imports(tool.content)) + if dependencies := frontmatter.get("requirements"): + all_dependencies += f"{dependencies}, " + + install_frontmatter_requirements(all_dependencies.strip(", ")) + except Exception as e: + log.error(f"Error installing requirements: {e}") diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index b5d916e1de..123ec5fb9c 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -36,7 +36,10 @@ from langchain_core.utils.function_calling import ( 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 -from open_webui.env import AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA +from open_webui.env import ( + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA, + AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, +) import copy @@ -276,8 +279,8 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]: docstring = func.__doc__ - description = parse_description(docstring) - function_descriptions = parse_docstring(docstring) + function_description = parse_description(docstring) + function_param_descriptions = parse_docstring(docstring) field_defs = {} for name, param in parameters.items(): @@ -285,15 +288,17 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]: type_hint = type_hints.get(name, Any) default_value = param.default if param.default is not param.empty else ... - description = function_descriptions.get(name, None) + param_description = function_param_descriptions.get(name, None) - if description: - field_defs[name] = type_hint, Field(default_value, description=description) + if param_description: + field_defs[name] = type_hint, Field( + default_value, description=param_description + ) else: field_defs[name] = type_hint, default_value model = create_model(func.__name__, **field_defs) - model.__doc__ = description + model.__doc__ = function_description return model @@ -371,51 +376,64 @@ def convert_openapi_to_tool_payload(openapi_spec): for path, methods in openapi_spec.get("paths", {}).items(): for method, operation in methods.items(): - tool = { - "type": "function", - "name": operation.get("operationId"), - "description": operation.get( - "description", operation.get("summary", "No description available.") - ), - "parameters": {"type": "object", "properties": {}, "required": []}, - } - - # Extract path and query parameters - for param in operation.get("parameters", []): - param_name = param["name"] - param_schema = param.get("schema", {}) - tool["parameters"]["properties"][param_name] = { - "type": param_schema.get("type"), - "description": param_schema.get("description", ""), + if operation.get("operationId"): + tool = { + "type": "function", + "name": operation.get("operationId"), + "description": operation.get( + "description", + operation.get("summary", "No description available."), + ), + "parameters": {"type": "object", "properties": {}, "required": []}, } - if param.get("required"): - tool["parameters"]["required"].append(param_name) - # Extract and resolve requestBody if available - request_body = operation.get("requestBody") - if request_body: - content = request_body.get("content", {}) - json_schema = content.get("application/json", {}).get("schema") - if json_schema: - resolved_schema = resolve_schema( - json_schema, openapi_spec.get("components", {}) - ) - - if resolved_schema.get("properties"): - tool["parameters"]["properties"].update( - resolved_schema["properties"] + # Extract path and query parameters + for param in operation.get("parameters", []): + param_name = param["name"] + param_schema = param.get("schema", {}) + description = param_schema.get("description", "") + if not description: + description = param.get("description") or "" + if param_schema.get("enum") and isinstance( + param_schema.get("enum"), list + ): + description += ( + f". Possible values: {', '.join(param_schema.get('enum'))}" ) - if "required" in resolved_schema: - tool["parameters"]["required"] = list( - set( - tool["parameters"]["required"] - + resolved_schema["required"] - ) - ) - elif resolved_schema.get("type") == "array": - tool["parameters"] = resolved_schema # special case for array + tool["parameters"]["properties"][param_name] = { + "type": param_schema.get("type"), + "description": description, + } + if param.get("required"): + tool["parameters"]["required"].append(param_name) - tool_payload.append(tool) + # Extract and resolve requestBody if available + request_body = operation.get("requestBody") + if request_body: + content = request_body.get("content", {}) + json_schema = content.get("application/json", {}).get("schema") + if json_schema: + resolved_schema = resolve_schema( + json_schema, openapi_spec.get("components", {}) + ) + + if resolved_schema.get("properties"): + tool["parameters"]["properties"].update( + resolved_schema["properties"] + ) + if "required" in resolved_schema: + tool["parameters"]["required"] = list( + set( + tool["parameters"]["required"] + + resolved_schema["required"] + ) + ) + elif resolved_schema.get("type") == "array": + tool["parameters"] = ( + resolved_schema # special case for array + ) + + tool_payload.append(tool) return tool_payload @@ -431,8 +449,10 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]: error = None try: timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(url, headers=headers) as response: + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL + ) as response: if response.status != 200: error_body = await response.json() raise Exception(error_body) @@ -573,19 +593,26 @@ async def execute_tool_server( if token: headers["Authorization"] = f"Bearer {token}" - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: request_method = getattr(session, http_method.lower()) if http_method in ["post", "put", "patch"]: async with request_method( - final_url, json=body_params, headers=headers + final_url, + json=body_params, + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, ) as response: if response.status >= 400: text = await response.text() raise Exception(f"HTTP error {response.status}: {text}") return await response.json() else: - async with request_method(final_url, headers=headers) as response: + async with request_method( + final_url, + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, + ) as response: if response.status >= 400: text = await response.text() raise Exception(f"HTTP error {response.status}: {text}") diff --git a/backend/requirements.txt b/backend/requirements.txt index f0cf262ee6..ce55d2d347 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -31,7 +31,7 @@ APScheduler==3.10.4 RestrictedPython==8.0 -loguru==0.7.2 +loguru==0.7.3 asgiref==3.8.1 # AI libraries @@ -40,8 +40,8 @@ anthropic google-generativeai==0.8.4 tiktoken -langchain==0.3.19 -langchain-community==0.3.18 +langchain==0.3.24 +langchain-community==0.3.23 fake-useragent==2.1.0 chromadb==0.6.3 @@ -49,11 +49,11 @@ pymilvus==2.5.0 qdrant-client~=1.12.0 opensearch-py==2.8.0 playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml -elasticsearch==8.17.1 - +elasticsearch==9.0.1 +pinecone==6.0.2 transformers -sentence-transformers==3.3.1 +sentence-transformers==4.1.0 accelerate colbert-ai==0.2.21 einops==0.8.1 @@ -81,7 +81,7 @@ azure-ai-documentintelligence==1.0.0 pillow==11.1.0 opencv-python-headless==4.11.0.86 -rapidocr-onnxruntime==1.3.24 +rapidocr-onnxruntime==1.4.4 rank-bm25==0.2.2 onnxruntime==1.20.1 @@ -107,7 +107,7 @@ google-auth-oauthlib ## Tests docker~=7.1.0 -pytest~=8.3.2 +pytest~=8.3.5 pytest-docker~=3.1.1 googleapis-common-protos==1.63.2 @@ -127,14 +127,14 @@ firecrawl-py==1.12.0 tencentcloud-sdk-python==3.0.1336 ## Trace -opentelemetry-api==1.31.1 -opentelemetry-sdk==1.31.1 -opentelemetry-exporter-otlp==1.31.1 -opentelemetry-instrumentation==0.52b1 -opentelemetry-instrumentation-fastapi==0.52b1 -opentelemetry-instrumentation-sqlalchemy==0.52b1 -opentelemetry-instrumentation-redis==0.52b1 -opentelemetry-instrumentation-requests==0.52b1 -opentelemetry-instrumentation-logging==0.52b1 -opentelemetry-instrumentation-httpx==0.52b1 -opentelemetry-instrumentation-aiohttp-client==0.52b1 +opentelemetry-api==1.32.1 +opentelemetry-sdk==1.32.1 +opentelemetry-exporter-otlp==1.32.1 +opentelemetry-instrumentation==0.53b1 +opentelemetry-instrumentation-fastapi==0.53b1 +opentelemetry-instrumentation-sqlalchemy==0.53b1 +opentelemetry-instrumentation-redis==0.53b1 +opentelemetry-instrumentation-requests==0.53b1 +opentelemetry-instrumentation-logging==0.53b1 +opentelemetry-instrumentation-httpx==0.53b1 +opentelemetry-instrumentation-aiohttp-client==0.53b1 diff --git a/backend/start.sh b/backend/start.sh index 4588e4c348..84d5ec8958 100755 --- a/backend/start.sh +++ b/backend/start.sh @@ -65,4 +65,6 @@ if [ -n "$SPACE_ID" ]; then export WEBUI_URL=${SPACE_HOST} fi -WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' --workers "${UVICORN_WORKERS:-1}" +PYTHON_CMD=$(command -v python3 || command -v python) + +WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' --workers "${UVICORN_WORKERS:-1}" diff --git a/contribution_stats.py b/contribution_stats.py new file mode 100644 index 0000000000..3caa4738ec --- /dev/null +++ b/contribution_stats.py @@ -0,0 +1,74 @@ +import os +import subprocess +from collections import Counter + +CONFIG_FILE_EXTENSIONS = (".json", ".yml", ".yaml", ".ini", ".conf", ".toml") + + +def is_text_file(filepath): + # Check for binary file by scanning for null bytes. + try: + with open(filepath, "rb") as f: + chunk = f.read(4096) + if b"\0" in chunk: + return False + return True + except Exception: + return False + + +def should_skip_file(path): + base = os.path.basename(path) + # Skip dotfiles and dotdirs + if base.startswith("."): + return True + # Skip config files by extension + if base.lower().endswith(CONFIG_FILE_EXTENSIONS): + return True + return False + + +def get_tracked_files(): + try: + output = subprocess.check_output(["git", "ls-files"], text=True) + files = output.strip().split("\n") + files = [f for f in files if f and os.path.isfile(f)] + return files + except subprocess.CalledProcessError: + print("Error: Are you in a git repository?") + return [] + + +def main(): + files = get_tracked_files() + email_counter = Counter() + total_lines = 0 + + for file in files: + if should_skip_file(file): + continue + if not is_text_file(file): + continue + try: + blame = subprocess.check_output( + ["git", "blame", "-e", file], text=True, errors="replace" + ) + for line in blame.splitlines(): + # The email always inside <> + if "<" in line and ">" in line: + try: + email = line.split("<")[1].split(">")[0].strip() + except Exception: + continue + email_counter[email] += 1 + total_lines += 1 + except subprocess.CalledProcessError: + continue + + for email, lines in email_counter.most_common(): + percent = (lines / total_lines * 100) if total_lines else 0 + print(f"{email}: {lines}/{total_lines} {percent:.2f}%") + + +if __name__ == "__main__": + main() diff --git a/docs/apache.md b/docs/apache.md index 1bd9205937..bdf119b5b6 100644 --- a/docs/apache.md +++ b/docs/apache.md @@ -1,6 +1,6 @@ # Hosting UI and Models separately -Sometimes, its beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users: +Sometimes, it's beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users: # Open WebUI Configuration diff --git a/package-lock.json b/package-lock.json index c4f6948bd4..ff0414895b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.6.5", + "version": "0.6.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.6.5", + "version": "0.6.9", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -18,8 +18,8 @@ "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^2.0.0", "@sveltejs/svelte-virtual-list": "^3.0.1", - "@tiptap/core": "^2.10.0", - "@tiptap/extension-code-block-lowlight": "^2.10.0", + "@tiptap/core": "^2.11.9", + "@tiptap/extension-code-block-lowlight": "^2.11.9", "@tiptap/extension-highlight": "^2.10.0", "@tiptap/extension-placeholder": "^2.10.0", "@tiptap/extension-typography": "^2.10.0", @@ -30,12 +30,13 @@ "bits-ui": "^0.19.7", "codemirror": "^6.0.1", "codemirror-lang-elixir": "^4.0.0", - "codemirror-lang-hcl": "^0.0.0-beta.2", + "codemirror-lang-hcl": "^0.1.0", "crc-32": "^1.2.2", "dayjs": "^1.11.10", "dompurify": "^3.2.5", "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", + "focus-trap": "^7.6.4", "fuse.js": "^7.0.0", "highlight.js": "^11.9.0", "html-entities": "^2.5.3", @@ -59,7 +60,7 @@ "prosemirror-markdown": "^1.13.1", "prosemirror-model": "^1.23.0", "prosemirror-schema-basic": "^1.2.3", - "prosemirror-schema-list": "^1.4.1", + "prosemirror-schema-list": "^1.5.1", "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.34.3", "pyodide": "^0.27.3", @@ -81,8 +82,8 @@ "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/postcss": "^4.0.0", "@tailwindcss/typography": "^0.5.13", - "@typescript-eslint/eslint-plugin": "^6.17.0", - "@typescript-eslint/parser": "^6.17.0", + "@typescript-eslint/eslint-plugin": "^8.31.1", + "@typescript-eslint/parser": "^8.31.1", "cypress": "^13.15.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", @@ -2890,9 +2891,9 @@ } }, "node_modules/@tiptap/core": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.10.0.tgz", - "integrity": "sha512-58nAjPxLRFcXepdDqQRC1mhrw6E8Sanqr6bbO4Tz0+FWgDJMZvHG+dOK5wHaDVNSgK2iJDz08ETvQayfOOgDvg==", + "version": "2.11.9", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.9.tgz", + "integrity": "sha512-UZSxQLLyJst47xep3jlyKM6y1ebZnmvbGsB7njBVjfxf5H+4yFpRJwwNqrBHM/vyU55LCtPChojqaYC1wXLf6g==", "license": "MIT", "funding": { "type": "github", @@ -2969,9 +2970,9 @@ } }, "node_modules/@tiptap/extension-code-block-lowlight": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.10.0.tgz", - "integrity": "sha512-dAv03XIHT5h+sdFmJzvx2FfpfFOOK9SBKHflRUdqTa8eA+0VZNAcPRjvJWVEWqts1fKZDJj774mO28NlhFzk9Q==", + "version": "2.11.9", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.11.9.tgz", + "integrity": "sha512-bB8N59A2aU18/ieyKRZAI0J0xyimmUckYePqBkUX8HFnq8yf9HsM0NPFpqZdK0eqjnZYCXcNwAI3YluLsHuutw==", "license": "MIT", "funding": { "type": "github", @@ -3547,12 +3548,6 @@ "@types/unist": "*" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -3604,12 +3599,6 @@ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==" }, - "node_modules/@types/semver": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "dev": true - }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", @@ -3652,79 +3641,72 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.1.tgz", + "integrity": "sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.31.1", + "@typescript-eslint/type-utils": "8.31.1", + "@typescript-eslint/utils": "8.31.1", + "@typescript-eslint/visitor-keys": "8.31.1", "graphemer": "^1.4.0", - "ignore": "^5.2.4", + "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.1.tgz", + "integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "8.31.1", + "@typescript-eslint/types": "8.31.1", + "@typescript-eslint/typescript-estree": "8.31.1", + "@typescript-eslint/visitor-keys": "8.31.1", "debug": "^4.3.4" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.1.tgz", + "integrity": "sha512-BMNLOElPxrtNQMIsFHE+3P0Yf1z0dJqV9zLdDxN/xLlWMlXK/ApEsVEKzpizg9oal8bAT5Sc7+ocal7AC1HCVw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "8.31.1", + "@typescript-eslint/visitor-keys": "8.31.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -3732,39 +3714,37 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.1.tgz", + "integrity": "sha512-fNaT/m9n0+dpSp8G/iOQ05GoHYXbxw81x+yvr7TArTuZuCA6VVKbqWYVZrV5dVagpDTtj/O8k5HBEE/p/HM5LA==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/typescript-estree": "8.31.1", + "@typescript-eslint/utils": "8.31.1", "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.1.tgz", + "integrity": "sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==", "dev": true, + "license": "MIT", "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -3772,73 +3752,85 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.1.tgz", + "integrity": "sha512-kaA0ueLe2v7KunYOyWYtlf/QhhZb7+qh4Yw6Ni5kgukMIG+iP773tjgBiLWIXYumWCwEq3nLW+TUywEp8uEeag==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/types": "8.31.1", + "@typescript-eslint/visitor-keys": "8.31.1", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "node_modules/@typescript-eslint/utils": { + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.1.tgz", + "integrity": "sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "eslint-visitor-keys": "^3.4.1" + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.31.1", + "@typescript-eslint/types": "8.31.1", + "@typescript-eslint/typescript-estree": "8.31.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.1.tgz", + "integrity": "sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.31.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/@ungap/structured-clone": { @@ -4166,15 +4158,6 @@ "dequal": "^2.0.3" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -4995,9 +4978,9 @@ } }, "node_modules/codemirror-lang-hcl": { - "version": "0.0.0-beta.2", - "resolved": "https://registry.npmjs.org/codemirror-lang-hcl/-/codemirror-lang-hcl-0.0.0-beta.2.tgz", - "integrity": "sha512-R3ew7Z2EYTdHTMXsWKBW9zxnLoLPYO+CrAa3dPZjXLrIR96Q3GR4cwJKF7zkSsujsnWgwRQZonyWpXYXfhQYuQ==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/codemirror-lang-hcl/-/codemirror-lang-hcl-0.1.0.tgz", + "integrity": "sha512-duwKEaQDhkJWad4YQ9pv4282BS6hCdR+gS/qTAj3f9bypXNNZ42bIN43h9WK3DjyZRENtVlUQdrQM1sA44wHmA==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -6022,18 +6005,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -6831,9 +6802,10 @@ "dev": true }, "node_modules/focus-trap": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz", - "integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.4.tgz", + "integrity": "sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==", + "license": "MIT", "dependencies": { "tabbable": "^6.2.0" } @@ -7154,26 +7126,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -8762,10 +8714,10 @@ } }, "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -8841,21 +8793,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/minizlib/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/minizlib/node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -9330,15 +9267,6 @@ "node": "14 || >=16.14" } }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -9860,9 +9788,10 @@ } }, "node_modules/prosemirror-schema-list": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.4.1.tgz", - "integrity": "sha512-jbDyaP/6AFfDfu70VzySsD75Om2t3sXTOdl5+31Wlxlg62td1haUpty/ybajSfJ1pkGadlOfwQq9kgW5IMo1Rg==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -11069,15 +10998,6 @@ "node": ">=18" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/slice-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", @@ -11842,15 +11762,16 @@ } }, "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-dedent": { diff --git a/package.json b/package.json index 0a982196e4..e7229fb5c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.6.5", + "version": "0.6.9", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -28,8 +28,8 @@ "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/postcss": "^4.0.0", "@tailwindcss/typography": "^0.5.13", - "@typescript-eslint/eslint-plugin": "^6.17.0", - "@typescript-eslint/parser": "^6.17.0", + "@typescript-eslint/eslint-plugin": "^8.31.1", + "@typescript-eslint/parser": "^8.31.1", "cypress": "^13.15.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", @@ -61,8 +61,8 @@ "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^2.0.0", "@sveltejs/svelte-virtual-list": "^3.0.1", - "@tiptap/core": "^2.10.0", - "@tiptap/extension-code-block-lowlight": "^2.10.0", + "@tiptap/core": "^2.11.9", + "@tiptap/extension-code-block-lowlight": "^2.11.9", "@tiptap/extension-highlight": "^2.10.0", "@tiptap/extension-placeholder": "^2.10.0", "@tiptap/extension-typography": "^2.10.0", @@ -73,12 +73,13 @@ "bits-ui": "^0.19.7", "codemirror": "^6.0.1", "codemirror-lang-elixir": "^4.0.0", - "codemirror-lang-hcl": "^0.0.0-beta.2", + "codemirror-lang-hcl": "^0.1.0", "crc-32": "^1.2.2", "dayjs": "^1.11.10", "dompurify": "^3.2.5", "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", + "focus-trap": "^7.6.4", "fuse.js": "^7.0.0", "highlight.js": "^11.9.0", "html-entities": "^2.5.3", @@ -102,7 +103,7 @@ "prosemirror-markdown": "^1.13.1", "prosemirror-model": "^1.23.0", "prosemirror-schema-basic": "^1.2.3", - "prosemirror-schema-list": "^1.4.1", + "prosemirror-schema-list": "^1.5.1", "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.34.3", "pyodide": "^0.27.3", diff --git a/pyproject.toml b/pyproject.toml index 8a48c90fac..bc04c4bf4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "RestrictedPython==8.0", - "loguru==0.7.2", + "loguru==0.7.3", "asgiref==3.8.1", "openai", @@ -48,8 +48,8 @@ dependencies = [ "google-generativeai==0.8.4", "tiktoken", - "langchain==0.3.19", - "langchain-community==0.3.18", + "langchain==0.3.24", + "langchain-community==0.3.23", "fake-useragent==2.1.0", "chromadb==0.6.3", @@ -57,10 +57,11 @@ dependencies = [ "qdrant-client~=1.12.0", "opensearch-py==2.8.0", "playwright==1.49.1", - "elasticsearch==8.17.1", + "elasticsearch==9.0.1", + "pinecone==6.0.2", "transformers", - "sentence-transformers==3.3.1", + "sentence-transformers==4.1.0", "accelerate", "colbert-ai==0.2.21", "einops==0.8.1", @@ -87,7 +88,7 @@ dependencies = [ "pillow==11.1.0", "opencv-python-headless==4.11.0.86", - "rapidocr-onnxruntime==1.3.24", + "rapidocr-onnxruntime==1.4.4", "rank-bm25==0.2.2", "onnxruntime==1.20.1", diff --git a/src/app.css b/src/app.css index d0bd50aced..5cfdd8df01 100644 --- a/src/app.css +++ b/src/app.css @@ -24,6 +24,12 @@ font-display: swap; } +@font-face { + font-family: 'Vazirmatn'; + src: url('/assets/fonts/Vazirmatn-Variable.ttf'); + font-display: swap; +} + html { word-break: break-word; } @@ -75,7 +81,7 @@ textarea::placeholder { } .font-primary { - font-family: 'Archivo', sans-serif; + font-family: 'Archivo', 'Vazirmatn', sans-serif; } .drag-region { diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index 40caebf5da..75252fd716 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -354,7 +354,8 @@ export const addUser = async ( name: string, email: string, password: string, - role: string = 'pending' + role: string = 'pending', + profile_image_url: null | string = null ) => { let error = null; @@ -368,7 +369,8 @@ export const addUser = async ( name: name, email: email, password: password, - role: role + role: role, + ...(profile_image_url && { profile_image_url: profile_image_url }) }) }) .then(async (res) => { diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index f16b43505f..cd46410c7d 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -1,5 +1,4 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; -import { t } from 'i18next'; type ChannelForm = { name: string; diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 0929e0a698..3892afeb8e 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -539,7 +539,7 @@ export const updateTaskConfig = async (token: string, config: object) => { export const generateTitle = async ( token: string = '', model: string, - messages: string[], + messages: object[], chat_id?: string ) => { let error = null; @@ -573,7 +573,39 @@ export const generateTitle = async ( throw error; } - return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat'; + try { + // Step 1: Safely extract the response string + const response = res?.choices[0]?.message?.content ?? ''; + + // Step 2: Attempt to fix common JSON format issues like single quotes + const sanitizedResponse = response.replace(/['‘’`]/g, '"'); // Convert single quotes to double quotes for valid JSON + + // Step 3: Find the relevant JSON block within the response + const jsonStartIndex = sanitizedResponse.indexOf('{'); + const jsonEndIndex = sanitizedResponse.lastIndexOf('}'); + + // Step 4: Check if we found a valid JSON block (with both `{` and `}`) + if (jsonStartIndex !== -1 && jsonEndIndex !== -1) { + const jsonResponse = sanitizedResponse.substring(jsonStartIndex, jsonEndIndex + 1); + + // Step 5: Parse the JSON block + const parsed = JSON.parse(jsonResponse); + + // Step 6: If there's a "tags" key, return the tags array; otherwise, return an empty array + if (parsed && parsed.title) { + return parsed.title; + } else { + return null; + } + } + + // If no valid JSON block found, return an empty array + return null; + } catch (e) { + // Catch and safely return empty array on any parsing errors + console.error('Failed to parse response: ', e); + return null; + } }; export const generateTags = async ( diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts new file mode 100644 index 0000000000..23bec36f25 --- /dev/null +++ b/src/lib/apis/notes/index.ts @@ -0,0 +1,187 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; +import { getTimeRange } from '$lib/utils'; + +type NoteItem = { + title: string; + data: object; + meta?: null | object; + access_control?: null | object; +}; + +export const createNewNote = async (token: string, note: NoteItem) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...note + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getNotes = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, { + 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.detail; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + if (!Array.isArray(res)) { + return {}; // or throw new Error("Notes response is not an array") + } + + // Build the grouped object + const grouped: Record = {}; + for (const note of res) { + const timeRange = getTimeRange(note.updated_at / 1000000000); + if (!grouped[timeRange]) { + grouped[timeRange] = []; + } + grouped[timeRange].push({ + ...note, + timeRange + }); + } + + return grouped; +}; + +export const getNoteById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}`, { + 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.detail; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateNoteById = async (token: string, id: string, note: NoteItem) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...note + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteNoteById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/delete`, { + method: 'DELETE', + 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.detail; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index f479b130c8..be82454c22 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -116,10 +116,33 @@ export const updateUserRole = async (token: string, id: string, role: string) => return res; }; -export const getUsers = async (token: string) => { +export const getUsers = async ( + token: string, + query?: string, + orderBy?: string, + direction?: string, + page = 1 +) => { let error = null; + let res = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/users/`, { + let searchParams = new URLSearchParams(); + + searchParams.set('page', `${page}`); + + if (query) { + searchParams.set('query', query); + } + + if (orderBy) { + searchParams.set('order_by', orderBy); + } + + if (direction) { + searchParams.set('direction', direction); + } + + res = await fetch(`${WEBUI_API_BASE_URL}/users/?${searchParams.toString()}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -140,7 +163,35 @@ export const getUsers = async (token: string) => { throw error; } - return res ? res : []; + return res; +}; + +export const getAllUsers = async (token: string) => { + let error = null; + let res = null; + + res = await fetch(`${WEBUI_API_BASE_URL}/users/all`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; }; export const getUserSettings = async (token: string) => { diff --git a/src/lib/components/AddFilesPlaceholder.svelte b/src/lib/components/AddFilesPlaceholder.svelte index d3d7007955..6d72ee0e61 100644 --- a/src/lib/components/AddFilesPlaceholder.svelte +++ b/src/lib/components/AddFilesPlaceholder.svelte @@ -21,7 +21,7 @@ {#if content} {content} {:else} - {$i18n.t('Drop any files here to add to the conversation')} + {$i18n.t('Drop any files here to upload')} {/if} diff --git a/src/lib/components/ChangelogModal.svelte b/src/lib/components/ChangelogModal.svelte index fd727c2c44..2e7dfa1993 100644 --- a/src/lib/components/ChangelogModal.svelte +++ b/src/lib/components/ChangelogModal.svelte @@ -43,6 +43,7 @@ fill="currentColor" class="w-5 h-5" > +

{$i18n.t('Close')}

diff --git a/src/lib/components/OnBoarding.svelte b/src/lib/components/OnBoarding.svelte index 1976e5c6e2..f0a4a52dc1 100644 --- a/src/lib/components/OnBoarding.svelte +++ b/src/lib/components/OnBoarding.svelte @@ -87,6 +87,7 @@
-
{$i18n.t(`Get started`)}
+
+ {$i18n.t(`Get started`)} +
- - {/if} diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte index 87f4958d47..fbeb615963 100644 --- a/src/lib/components/admin/Functions.svelte +++ b/src/lib/components/admin/Functions.svelte @@ -192,7 +192,7 @@ - {$i18n.t('Functions')} | {$WEBUI_NAME} + {$i18n.t('Functions')} • {$WEBUI_NAME} diff --git a/src/lib/components/admin/Functions/FunctionEditor.svelte b/src/lib/components/admin/Functions/FunctionEditor.svelte index 6da2a83f45..9b2355fe45 100644 --- a/src/lib/components/admin/Functions/FunctionEditor.svelte +++ b/src/lib/components/admin/Functions/FunctionEditor.svelte @@ -387,7 +387,7 @@ class Pipe:
{$i18n.t('Warning:')} - {$i18n.t('Functions allow arbitrary code execution')}
— + {$i18n.t('Functions allow arbitrary code execution.')}
{$i18n.t(`don't install random functions from sources you don't trust.`)} diff --git a/src/lib/components/admin/Settings/Audio.svelte b/src/lib/components/admin/Settings/Audio.svelte index 52b8749352..960f3497ac 100644 --- a/src/lib/components/admin/Settings/Audio.svelte +++ b/src/lib/components/admin/Settings/Audio.svelte @@ -32,6 +32,7 @@ let TTS_VOICE = ''; let TTS_SPLIT_ON: TTS_RESPONSE_SPLIT = TTS_RESPONSE_SPLIT.PUNCTUATION; let TTS_AZURE_SPEECH_REGION = ''; + let TTS_AZURE_SPEECH_BASE_URL = ''; let TTS_AZURE_SPEECH_OUTPUT_FORMAT = ''; let STT_OPENAI_API_BASE_URL = ''; @@ -42,6 +43,8 @@ let STT_AZURE_API_KEY = ''; let STT_AZURE_REGION = ''; let STT_AZURE_LOCALES = ''; + let STT_AZURE_BASE_URL = ''; + let STT_AZURE_MAX_SPEAKERS = ''; let STT_DEEPGRAM_API_KEY = ''; let STT_WHISPER_MODEL_LOADING = false; @@ -103,6 +106,7 @@ VOICE: TTS_VOICE, SPLIT_ON: TTS_SPLIT_ON, AZURE_SPEECH_REGION: TTS_AZURE_SPEECH_REGION, + AZURE_SPEECH_BASE_URL: TTS_AZURE_SPEECH_BASE_URL, AZURE_SPEECH_OUTPUT_FORMAT: TTS_AZURE_SPEECH_OUTPUT_FORMAT }, stt: { @@ -114,7 +118,9 @@ DEEPGRAM_API_KEY: STT_DEEPGRAM_API_KEY, AZURE_API_KEY: STT_AZURE_API_KEY, AZURE_REGION: STT_AZURE_REGION, - AZURE_LOCALES: STT_AZURE_LOCALES + AZURE_LOCALES: STT_AZURE_LOCALES, + AZURE_BASE_URL: STT_AZURE_BASE_URL, + AZURE_MAX_SPEAKERS: STT_AZURE_MAX_SPEAKERS } }); @@ -145,8 +151,9 @@ TTS_SPLIT_ON = res.tts.SPLIT_ON || TTS_RESPONSE_SPLIT.PUNCTUATION; - TTS_AZURE_SPEECH_OUTPUT_FORMAT = res.tts.AZURE_SPEECH_OUTPUT_FORMAT; TTS_AZURE_SPEECH_REGION = res.tts.AZURE_SPEECH_REGION; + TTS_AZURE_SPEECH_BASE_URL = res.tts.AZURE_SPEECH_BASE_URL; + TTS_AZURE_SPEECH_OUTPUT_FORMAT = res.tts.AZURE_SPEECH_OUTPUT_FORMAT; STT_OPENAI_API_BASE_URL = res.stt.OPENAI_API_BASE_URL; STT_OPENAI_API_KEY = res.stt.OPENAI_API_KEY; @@ -157,6 +164,8 @@ STT_AZURE_API_KEY = res.stt.AZURE_API_KEY; STT_AZURE_REGION = res.stt.AZURE_REGION; STT_AZURE_LOCALES = res.stt.AZURE_LOCALES; + STT_AZURE_BASE_URL = res.stt.AZURE_BASE_URL; + STT_AZURE_MAX_SPEAKERS = res.stt.AZURE_MAX_SPEAKERS; STT_DEEPGRAM_API_KEY = res.stt.DEEPGRAM_API_KEY; } @@ -266,16 +275,23 @@ bind:value={STT_AZURE_API_KEY} required /> -

+
+
{$i18n.t('Azure Region')}
+
+
+ +
+
+
+
{$i18n.t('Language Locales')}
@@ -288,6 +304,32 @@
+ +
+
{$i18n.t('Endpoint URL')}
+
+
+ +
+
+
+ +
+
{$i18n.t('Max Speakers')}
+
+
+ +
+
+
{:else if STT_ENGINE === ''}
@@ -436,18 +478,35 @@ {:else if TTS_ENGINE === 'azure'}
- - + +
+ +
+ +
+
{$i18n.t('Azure Region')}
+
+
+ +
+
+
+ +
+
{$i18n.t('Endpoint URL')}
+
+
+ +
+
{/if} diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 2047a07e76..cc56356fa7 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -123,35 +123,6 @@ } }; - const rerankingModelUpdateHandler = async () => { - console.log('Update reranking model attempt:', rerankingModel); - - updateRerankingModelLoading = true; - const res = await updateRerankingConfig(localStorage.token, { - reranking_model: rerankingModel - }).catch(async (error) => { - toast.error(`${error}`); - await setRerankingConfig(); - return null; - }); - updateRerankingModelLoading = false; - - if (res) { - console.log('rerankingModelUpdateHandler:', res); - if (res.status === true) { - if (rerankingModel === '') { - toast.success($i18n.t('Reranking model disabled', res), { - duration: 1000 * 10 - }); - } else { - toast.success($i18n.t('Reranking model set to "{{reranking_model}}"', res), { - duration: 1000 * 10 - }); - } - } - } - }; - const submitHandler = async () => { if (RAGConfig.CONTENT_EXTRACTION_ENGINE === 'tika' && RAGConfig.TIKA_SERVER_URL === '') { toast.error($i18n.t('Tika Server URL required.')); @@ -161,6 +132,16 @@ toast.error($i18n.t('Docling Server URL required.')); return; } + if ( + RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' && + ((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 === 'document_intelligence' && @@ -180,10 +161,6 @@ if (!RAGConfig.BYPASS_EMBEDDING_AND_RETRIEVAL) { await embeddingModelUpdateHandler(); - - if (RAGConfig.ENABLE_RAG_HYBRID_SEARCH) { - await rerankingModelUpdateHandler(); - } } const res = await updateRAGConfig(localStorage.token, RAGConfig); @@ -205,18 +182,8 @@ OllamaUrl = embeddingConfig.ollama_config.url; } }; - - const setRerankingConfig = async () => { - const rerankingConfig = await getRerankingConfig(localStorage.token); - - if (rerankingConfig) { - rerankingModel = rerankingConfig.reranking_model; - } - }; - onMount(async () => { await setEmbeddingConfig(); - await setRerankingConfig(); RAGConfig = await getRAGConfig(localStorage.token); }); @@ -326,6 +293,18 @@ bind:value={RAGConfig.DOCLING_SERVER_URL} />
+
+ + +
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence'}
{#if RAGConfig.ENABLE_RAG_HYBRID_SEARCH === true} +
+
+
+ {$i18n.t('Reranking Engine')} +
+
+ +
+
+ + {#if RAGConfig.RAG_RERANKING_ENGINE === 'external'} +
+ + + +
+ {/if} +
+
{$i18n.t('Reranking Model')}
@@ -644,62 +665,9 @@ placeholder={$i18n.t('Set reranking model (e.g. {{model}})', { model: 'BAAI/bge-reranker-v2-m3' })} - bind:value={rerankingModel} + bind:value={RAGConfig.RAG_RERANKING_MODEL} />
-
diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index 5c50bf3110..3741168f88 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -1,7 +1,7 @@ -{#if taskConfig} +{#if models !== null && taskConfig}
{ @@ -105,9 +139,27 @@ class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden" bind:value={taskConfig.TASK_MODEL} placeholder={$i18n.t('Select a model')} + on:change={() => { + if (taskConfig.TASK_MODEL) { + const model = models.find((m) => m.id === taskConfig.TASK_MODEL); + if (model) { + if (model?.access_control !== null) { + toast.error( + $i18n.t( + 'This model is not publicly available. Please select another model.' + ) + ); + } + + taskConfig.TASK_MODEL = model.id; + } else { + taskConfig.TASK_MODEL = ''; + } + } + }} > - {#each $models.filter((m) => m.owned_by === 'ollama') as model} + {#each models.filter((m) => m.owned_by === 'ollama') as model} @@ -121,9 +173,27 @@ class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden" bind:value={taskConfig.TASK_MODEL_EXTERNAL} placeholder={$i18n.t('Select a model')} + on:change={() => { + if (taskConfig.TASK_MODEL_EXTERNAL) { + const model = models.find((m) => m.id === taskConfig.TASK_MODEL_EXTERNAL); + if (model) { + if (model?.access_control !== null) { + toast.error( + $i18n.t( + 'This model is not publicly available. Please select another model.' + ) + ); + } + + taskConfig.TASK_MODEL_EXTERNAL = model.id; + } else { + taskConfig.TASK_MODEL_EXTERNAL = ''; + } + } + }} > - {#each $models as model} + {#each models as model} @@ -480,4 +550,8 @@
+{:else} +
+ +
{/if} diff --git a/src/lib/components/admin/Settings/WebSearch.svelte b/src/lib/components/admin/Settings/WebSearch.svelte index d9771f8354..e25852cc36 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -14,6 +14,7 @@ let webSearchEngines = [ 'searxng', + 'yacy', 'google_pse', 'brave', 'kagi', @@ -30,9 +31,11 @@ 'bing', 'exa', 'perplexity', - 'sougou' + 'sougou', + 'firecrawl', + 'external' ]; - let webLoaderEngines = ['playwright', 'firecrawl', 'tavily']; + let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'external']; let webConfig = null; @@ -143,6 +146,53 @@ + {:else if webConfig.WEB_SEARCH_ENGINE === 'yacy'} +
+
+
+ {$i18n.t('Yacy Instance URL')} +
+ +
+
+ +
+
+
+
+
+
+
+
+ {$i18n.t('Yacy Username')} +
+ + +
+ +
+
+ {$i18n.t('Yacy Password')} +
+ + +
+
+
{:else if webConfig.WEB_SEARCH_ENGINE === 'google_pse'}
@@ -431,6 +481,68 @@ />
+ {:else if webConfig.WEB_SEARCH_ENGINE === 'firecrawl'} +
+
+
+ {$i18n.t('Firecrawl API Base URL')} +
+ +
+
+ +
+
+
+ +
+
+ {$i18n.t('Firecrawl API Key')} +
+ + +
+
+ {:else if webConfig.WEB_SEARCH_ENGINE === 'external'} +
+
+
+ {$i18n.t('External Web Search URL')} +
+ +
+
+ +
+
+
+ +
+
+ {$i18n.t('External Web Search API Key')} +
+ + +
+
{/if} {/if} @@ -588,7 +700,7 @@ - {:else if webConfig.WEB_LOADER_ENGINE === 'firecrawl'} + {:else if webConfig.WEB_LOADER_ENGINE === 'firecrawl' && webConfig.WEB_SEARCH_ENGINE !== 'firecrawl'}
@@ -652,6 +764,37 @@
{/if}
+ {:else if webConfig.WEB_LOADER_ENGINE === 'external'} +
+
+
+ {$i18n.t('External Web Loader URL')} +
+ +
+
+ +
+
+
+ +
+
+ {$i18n.t('External Web Loader API Key')} +
+ + +
+
{/if}
diff --git a/src/lib/components/admin/Users.svelte b/src/lib/components/admin/Users.svelte index 00659ad3ea..e777757e0e 100644 --- a/src/lib/components/admin/Users.svelte +++ b/src/lib/components/admin/Users.svelte @@ -5,32 +5,19 @@ import { goto } from '$app/navigation'; import { user } from '$lib/stores'; - import { getUsers } from '$lib/apis/users'; - import UserList from './Users/UserList.svelte'; import Groups from './Users/Groups.svelte'; const i18n = getContext('i18n'); - let users = []; - let selectedTab = 'overview'; let loaded = false; - $: if (selectedTab) { - getUsersHandler(); - } - - const getUsersHandler = async () => { - users = await getUsers(localStorage.token); - }; - onMount(async () => { if ($user?.role !== 'admin') { await goto('/'); - } else { - users = await getUsers(localStorage.token); } + loaded = true; const containerElement = document.getElementById('users-tabs-container'); @@ -102,9 +89,9 @@
{#if selectedTab === 'overview'} - + {:else if selectedTab === 'groups'} - + {/if}
diff --git a/src/lib/components/admin/Users/Groups.svelte b/src/lib/components/admin/Users/Groups.svelte index dce8423e5d..5847ebbb18 100644 --- a/src/lib/components/admin/Users/Groups.svelte +++ b/src/lib/components/admin/Users/Groups.svelte @@ -23,13 +23,18 @@ import GroupItem from './Groups/GroupItem.svelte'; import AddGroupModal from './Groups/AddGroupModal.svelte'; import { createNewGroup, getGroups } from '$lib/apis/groups'; - import { getUserDefaultPermissions, updateUserDefaultPermissions } from '$lib/apis/users'; + import { + getUserDefaultPermissions, + getAllUsers, + updateUserDefaultPermissions + } from '$lib/apis/users'; const i18n = getContext('i18n'); let loaded = false; - export let users = []; + let users = []; + let total = 0; let groups = []; let filteredGroups; @@ -63,6 +68,8 @@ file_upload: true, delete: true, edit: true, + share: true, + export: true, stt: true, tts: true, call: true, @@ -74,7 +81,8 @@ direct_tool_servers: false, web_search: true, image_generation: true, - code_interpreter: true + code_interpreter: true, + notes: true } }; @@ -116,10 +124,22 @@ onMount(async () => { if ($user?.role !== 'admin') { await goto('/'); - } else { - await setGroups(); - defaultPermissions = await getUserDefaultPermissions(localStorage.token); + return; } + + const res = await getAllUsers(localStorage.token).catch((error) => { + toast.error(`${error}`); + return null; + }); + + if (res) { + users = res.users; + total = res.total; + } + + await setGroups(); + defaultPermissions = await getUserDefaultPermissions(localStorage.token); + loaded = true; }); diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index c7a1308a5b..6af935813b 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -24,6 +24,8 @@ file_upload: true, delete: true, edit: true, + share: true, + export: true, stt: true, tts: true, call: true, @@ -35,7 +37,8 @@ direct_tool_servers: false, web_search: true, image_generation: true, - code_interpreter: true + code_interpreter: true, + notes: true } }; @@ -276,6 +279,22 @@
+
+
+ {$i18n.t('Allow Chat Share')} +
+ + +
+ +
+
+ {$i18n.t('Allow Chat Export')} +
+ + +
+
{$i18n.t('Allow Speech to Text')} @@ -362,5 +381,13 @@
+ +
+
+ {$i18n.t('Notes')} +
+ + +
diff --git a/src/lib/components/admin/Users/UserList.svelte b/src/lib/components/admin/Users/UserList.svelte index 02fcdd7172..61816780ba 100644 --- a/src/lib/components/admin/Users/UserList.svelte +++ b/src/lib/components/admin/Users/UserList.svelte @@ -23,6 +23,8 @@ import AddUserModal from '$lib/components/admin/Users/UserList/AddUserModal.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; + import RoleUpdateConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; + import Badge from '$lib/components/common/Badge.svelte'; import Plus from '$lib/components/icons/Plus.svelte'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; @@ -30,22 +32,37 @@ import About from '$lib/components/chat/Settings/About.svelte'; import Banner from '$lib/components/common/Banner.svelte'; import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; + import Spinner from '$lib/components/common/Spinner.svelte'; const i18n = getContext('i18n'); - export let users = []; - - let search = ''; - let selectedUser = null; - let page = 1; + let users = null; + let total = null; + + let query = ''; + let orderBy = 'created_at'; // default sort key + let direction = 'asc'; // default sort order + + let selectedUser = null; + let showDeleteConfirmDialog = false; let showAddUserModal = false; let showUserChatsModal = false; let showEditUserModal = false; + let showUpdateRoleModal = false; + const onUpdateRole = (user) => { + if (user.role === 'user') { + updateRoleHandler(user.id, 'admin'); + } else if (user.role === 'pending') { + updateRoleHandler(user.id, 'user'); + } else { + updateRoleHandler(user.id, 'pending'); + } + }; const updateRoleHandler = async (id, role) => { const res = await updateUserRole(localStorage.token, id, role).catch((error) => { toast.error(`${error}`); @@ -53,7 +70,7 @@ }); if (res) { - users = await getUsers(localStorage.token); + getUserList(); } }; @@ -62,42 +79,51 @@ toast.error(`${error}`); return null; }); + + // if the user is deleted and the current page has only one user, go back to the previous page + if (users.length === 1 && page > 1) { + page -= 1; + } + if (res) { - users = await getUsers(localStorage.token); + getUserList(); } }; - let sortKey = 'created_at'; // default sort key - let sortOrder = 'asc'; // default sort order - - function setSortKey(key) { - if (sortKey === key) { - sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + const setSortKey = (key) => { + if (orderBy === key) { + direction = direction === 'asc' ? 'desc' : 'asc'; } else { - sortKey = key; - sortOrder = 'asc'; + orderBy = key; + direction = 'asc'; } + }; + + const getUserList = async () => { + try { + const res = await getUsers(localStorage.token, query, orderBy, direction, page).catch( + (error) => { + toast.error(`${error}`); + return null; + } + ); + + if (res) { + users = res.users; + total = res.total; + } + } catch (err) { + console.error(err); + } + }; + + $: if (page) { + getUserList(); } - let filteredUsers; - - $: filteredUsers = users - .filter((user) => { - if (search === '') { - return true; - } else { - let name = user.name.toLowerCase(); - let email = user.email.toLowerCase(); - const query = search.toLowerCase(); - return name.includes(query) || email.includes(query); - } - }) - .sort((a, b) => { - if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1; - if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1; - return 0; - }) - .slice((page - 1) * 20, page * 20); + $: if (query !== null && orderBy && direction) { + getUserList(); + } + { + onUpdateRole(selectedUser); + }} + message={$i18n.t(`Are you sure you want to update this user\'s role to **{{ROLE}}**?`, { + ROLE: + selectedUser?.role === 'user' + ? 'admin' + : selectedUser?.role === 'pending' + ? 'user' + : 'pending' + })} +/> + {#key selectedUser} { - users = await getUsers(localStorage.token); + getUserList(); }} /> {/key} @@ -121,12 +162,12 @@ { - users = await getUsers(localStorage.token); + getUserList(); }} /> -{#if ($config?.license_metadata?.seats ?? null) !== null && users.length > $config?.license_metadata?.seats} +{#if ($config?.license_metadata?.seats ?? null) !== null && total && total > $config?.license_metadata?.seats}
{/if} -
-
-
- {$i18n.t('Users')} -
-
+{#if users === null || total === null} +
+ +
+{:else} +
+
+
+ {$i18n.t('Users')} +
+
- {#if ($config?.license_metadata?.seats ?? null) !== null} - {#if users.length > $config?.license_metadata?.seats} - {users.length} of {$config?.license_metadata?.seats} - available users + {#if ($config?.license_metadata?.seats ?? null) !== null} + {#if total > $config?.license_metadata?.seats} + {total} of {$config?.license_metadata?.seats} + available users + {:else} + {total} of {$config?.license_metadata?.seats} + available users + {/if} {:else} - {users.length} of {$config?.license_metadata?.seats} - available users + {total} {/if} - {:else} - {users.length} - {/if} -
- -
-
-
-
- - - -
- -
- -
- - - -
-
-
-
- - - - - - - - - - - - - - - - {#each filteredUsers as user, userIdx} - - - - +
+
setSortKey('role')} - > -
- {$i18n.t('Role')} - - {#if sortKey === 'role'} - {#if sortOrder === 'asc'} - - {:else} - - {/if} - - {:else} - - {/if} +
+
+
+
+ + +
-
setSortKey('name')} - > -
- {$i18n.t('Name')} + +
- {#if sortKey === 'name'} - {#if sortOrder === 'asc'} - - {:else} - - {/if} - - {:else} - - {/if} - -
setSortKey('email')} - > -
- {$i18n.t('Email')} - - {#if sortKey === 'email'} - {#if sortOrder === 'asc'} - - {:else} - - {/if} - - {:else} - - {/if} -
-
setSortKey('last_active_at')} - > -
- {$i18n.t('Last Active')} - - {#if sortKey === 'last_active_at'} - {#if sortOrder === 'asc'} - - {:else} - - {/if} - - {:else} - - {/if} -
-
setSortKey('created_at')} - > -
- {$i18n.t('Created at')} - {#if sortKey === 'created_at'} - {#if sortOrder === 'asc'} - - {:else} - - {/if} - - {:else} - - {/if} -
-
setSortKey('oauth_sub')} - > -
- {$i18n.t('OAuth ID')} - - {#if sortKey === 'oauth_sub'} - {#if sortOrder === 'asc'} - - {:else} - - {/if} - - {:else} - - {/if} -
-
-
+
+ -
-
- user + +
+ + + -
{user.name}
- -
{user.email}
+ + + - - - - - - + + + + + + + + + + + {#each users as user, userIdx} + + + + + + + + + + + + - - {/each} - -
setSortKey('role')} + > +
+ {$i18n.t('Role')} -
- {dayjs(user.last_active_at * 1000).fromNow()} - - {dayjs(user.created_at * 1000).format('LL')} - {user.oauth_sub ?? ''} -
- {#if $config.features.enable_admin_chat_access && user.role !== 'admin'} - - - + {#if orderBy === 'role'} + {#if direction === 'asc'} + + {:else} + + {/if} + + {:else} + {/if} +
+ +
setSortKey('name')} + > +
+ {$i18n.t('Name')} - - - + {#if orderBy === 'name'} + {#if direction === 'asc'} + + {:else} + + {/if} + + {:else} + + {/if} +
+
setSortKey('email')} + > +
+ {$i18n.t('Email')} - {#if user.role !== 'admin'} - + {#if orderBy === 'email'} + {#if direction === 'asc'} + + {:else} + + {/if} + + {:else} + + {/if} +
+
setSortKey('last_active_at')} + > +
+ {$i18n.t('Last Active')} + + {#if orderBy === 'last_active_at'} + {#if direction === 'asc'} + + {:else} + + {/if} + + {:else} + + {/if} +
+
setSortKey('created_at')} + > +
+ {$i18n.t('Created at')} + {#if orderBy === 'created_at'} + {#if direction === 'asc'} + + {:else} + + {/if} + + {:else} + + {/if} +
+
setSortKey('oauth_sub')} + > +
+ {$i18n.t('OAuth ID')} + + {#if orderBy === 'oauth_sub'} + {#if direction === 'asc'} + + {:else} + + {/if} + + {:else} + + {/if} +
+
+
+ + +
+ user + +
{user.name}
+
+
{user.email} + {dayjs(user.last_active_at * 1000).fromNow()} + + {dayjs(user.created_at * 1000).format('LL')} + {user.oauth_sub ?? ''} +
+ {#if $config.features.enable_admin_chat_access && user.role !== 'admin'} + + + + {/if} + + - {/if} -
-
-
-
- ⓘ {$i18n.t("Click on the user role button to change a user's role.")} -
+ {#if user.role !== 'admin'} + + + + {/if} +
+ + + {/each} + + +
- +
+ ⓘ {$i18n.t("Click on the user role button to change a user's role.")} +
+ + +{/if} {#if !$config?.license_metadata} - {#if users.length > 50} + {#if total > 50}
{ toast.error(`${error}`); }); @@ -83,7 +85,8 @@ columns[0], columns[1], columns[2], - columns[3].toLowerCase() + columns[3].toLowerCase(), + generateInitialsImage(columns[0]) ).catch((error) => { toast.error(`Row ${idx + 1}: ${error}`); return null; @@ -109,7 +112,7 @@ stopLoading(); }; - reader.readAsText(file); + reader.readAsText(file, 'utf-8'); } else { toast.error($i18n.t('File not found.')); } diff --git a/src/lib/components/admin/Users/UserList/EditUserModal.svelte b/src/lib/components/admin/Users/UserList/EditUserModal.svelte index cec911d08d..f8a83f0db5 100644 --- a/src/lib/components/admin/Users/UserList/EditUserModal.svelte +++ b/src/lib/components/admin/Users/UserList/EditUserModal.svelte @@ -45,7 +45,7 @@
-
+
{$i18n.t('Edit User')}
-
-
+
-
+
-
+
+
+
+
{$i18n.t('Email')}
-
-
-
{$i18n.t('Email')}
+
+ +
+
-
- +
+
{$i18n.t('Name')}
+ +
+ +
+
+ +
+
{$i18n.t('New Password')}
+ +
+ +
-
-
{$i18n.t('Name')}
- -
- -
+
+
- -
-
{$i18n.t('New Password')}
- -
- -
-
-
- -
-
diff --git a/src/lib/components/channel/Channel.svelte b/src/lib/components/channel/Channel.svelte index ce2aa54f1c..1b1ca25de1 100644 --- a/src/lib/components/channel/Channel.svelte +++ b/src/lib/components/channel/Channel.svelte @@ -195,7 +195,7 @@ - #{channel?.name ?? 'Channel'} | Open WebUI + #{channel?.name ?? 'Channel'} • Open WebUI
{ + onClose={() => { threadId = null; }} > diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index 9f495a8de1..d60851fc8b 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -357,14 +357,14 @@ {#if recording} { + onCancel={async () => { recording = false; await tick(); document.getElementById(`chat-input-${id}`)?.focus(); }} - on:confirm={async (e) => { - const { text, filename } = e.detail; + onConfirm={async (data) => { + const { text, filename } = data; content = `${content}${text} `; recording = false; diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index fb9faa2470..7e3e3843ea 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -91,7 +91,7 @@ export let chatIdProp = ''; - let loading = false; + let loading = true; const eventTarget = new EventTarget(); let controlPane; @@ -142,7 +142,6 @@ $: if (chatIdProp) { (async () => { loading = true; - console.log(chatIdProp); prompt = ''; files = []; @@ -150,22 +149,26 @@ webSearchEnabled = false; imageGenerationEnabled = false; - if (chatIdProp && (await loadChat())) { - await tick(); - loading = false; - - if (localStorage.getItem(`chat-input-${chatIdProp}`)) { - try { - const input = JSON.parse(localStorage.getItem(`chat-input-${chatIdProp}`)); + if (localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) { + try { + const input = JSON.parse( + localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`) + ); + if (!$temporaryChatEnabled) { prompt = input.prompt; files = input.files; selectedToolIds = input.selectedToolIds; webSearchEnabled = input.webSearchEnabled; imageGenerationEnabled = input.imageGenerationEnabled; - } catch (e) {} - } + codeInterpreterEnabled = input.codeInterpreterEnabled; + } + } catch (e) {} + } + if (chatIdProp && (await loadChat())) { + await tick(); + loading = false; window.setTimeout(() => scrollToBottom(), 0); const chatInput = document.getElementById('chat-input'); chatInput?.focus(); @@ -236,9 +239,11 @@ await tick(); await tick(); - const messageElement = document.getElementById(`message-${message.id}`); - if (messageElement) { - messageElement.scrollIntoView({ behavior: 'smooth' }); + if ($settings?.scrollOnBranchChange ?? true) { + const messageElement = document.getElementById(`message-${message.id}`); + if (messageElement) { + messageElement.scrollIntoView({ behavior: 'smooth' }); + } } await tick(); @@ -397,6 +402,7 @@ }; onMount(async () => { + loading = true; console.log('mounted'); window.addEventListener('message', onMessageHandler); $socket?.on('chat-events', chatEventHandler); @@ -414,21 +420,33 @@ } } - if (localStorage.getItem(`chat-input-${chatIdProp}`)) { + if (localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) { + prompt = ''; + files = []; + selectedToolIds = []; + webSearchEnabled = false; + imageGenerationEnabled = false; + codeInterpreterEnabled = false; + try { - const input = JSON.parse(localStorage.getItem(`chat-input-${chatIdProp}`)); - prompt = input.prompt; - files = input.files; - selectedToolIds = input.selectedToolIds; - webSearchEnabled = input.webSearchEnabled; - imageGenerationEnabled = input.imageGenerationEnabled; - } catch (e) { - prompt = ''; - files = []; - selectedToolIds = []; - webSearchEnabled = false; - imageGenerationEnabled = false; - } + const input = JSON.parse( + localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`) + ); + + if (!$temporaryChatEnabled) { + prompt = input.prompt; + files = input.files; + selectedToolIds = input.selectedToolIds; + webSearchEnabled = input.webSearchEnabled; + imageGenerationEnabled = input.imageGenerationEnabled; + codeInterpreterEnabled = input.codeInterpreterEnabled; + } + } catch (e) {} + } + + if (!chatIdProp) { + loading = false; + await tick(); } showControls.subscribe(async (value) => { @@ -1482,9 +1500,20 @@ }; const sendPromptSocket = async (_history, model, responseMessageId, _chatId) => { + const chatMessages = createMessagesList(history, history.currentId); const responseMessage = _history.messages[responseMessageId]; const userMessage = _history.messages[responseMessage.parentId]; + const chatMessageFiles = chatMessages + .filter((message) => message.files) + .flatMap((message) => message.files); + + // Filter chatFiles to only include files that are in the chatMessageFiles + chatFiles = chatFiles.filter((item) => { + const fileExists = chatMessageFiles.some((messageFile) => messageFile.id === item.id); + return fileExists; + }); + let files = JSON.parse(JSON.stringify(chatFiles)); files.push( ...(userMessage?.files ?? []).filter((item) => @@ -1919,7 +1948,7 @@ {$chatTitle - ? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} | ${$WEBUI_NAME}` + ? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}` : `${$WEBUI_NAME}`} @@ -2038,10 +2067,13 @@ {stopResponse} {createMessagePair} onChange={(input) => { - if (input.prompt) { - localStorage.setItem(`chat-input-${$chatId}`, JSON.stringify(input)); + if (input.prompt !== null) { + localStorage.setItem( + `chat-input${$chatId ? `-${$chatId}` : ''}`, + JSON.stringify(input) + ); } else { - localStorage.removeItem(`chat-input-${$chatId}`); + localStorage.removeItem(`chat-input${$chatId ? `-${$chatId}` : ''}`); } }} on:upload={async (e) => { diff --git a/src/lib/components/chat/ChatControls.svelte b/src/lib/components/chat/ChatControls.svelte index 92c7e4d8d0..64fd8d92d3 100644 --- a/src/lib/components/chat/ChatControls.svelte +++ b/src/lib/components/chat/ChatControls.svelte @@ -140,7 +140,7 @@ {#if $showControls} { + onClose={() => { showControls.set(false); }} > diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index ca6487cf58..9e5593ea9f 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -86,10 +86,11 @@ $: onChange({ prompt, - files, + files: files.filter((file) => file.type !== 'image'), selectedToolIds, imageGenerationEnabled, - webSearchEnabled + webSearchEnabled, + codeInterpreterEnabled }); let showTools = false; @@ -395,39 +396,37 @@
- {#if atSelectedModel !== undefined || selectedToolIds.length > 0 || webSearchEnabled || ($settings?.webSearch ?? false) === 'always' || imageGenerationEnabled || codeInterpreterEnabled} + {#if atSelectedModel !== undefined}
- {#if atSelectedModel !== undefined} -
-
- model profile model.id === atSelectedModel.id)?.info?.meta - ?.profile_image_url ?? - ($i18n.language === 'dg-DG' - ? `/doge.png` - : `${WEBUI_BASE_URL}/static/favicon.png`)} - /> -
- Talking to {atSelectedModel.name} -
-
-
- +
+
+ model profile model.id === atSelectedModel.id)?.info?.meta + ?.profile_image_url ?? + ($i18n.language === 'dg-DG' + ? `/doge.png` + : `${WEBUI_BASE_URL}/static/favicon.png`)} + /> +
+ Talking to {atSelectedModel.name}
- {/if} +
+ +
+
{/if} @@ -481,14 +480,14 @@ {#if recording} { + onCancel={async () => { recording = false; await tick(); document.getElementById('chat-input')?.focus(); }} - on:confirm={async (e) => { - const { text, filename } = e.detail; + onConfirm={async (data) => { + const { text, filename } = data; prompt = `${prompt}${text} `; recording = false; @@ -605,7 +604,7 @@
{#if $settings?.richTextInput ?? true}
{ + uploadOneDriveHandler={async (authorityType) => { try { - const fileData = await pickAndDownloadFile(); + const fileData = await pickAndDownloadFile(authorityType); if (fileData) { const file = new File([fileData.blob], fileData.name, { type: fileData.blob.type || 'application/octet-stream' diff --git a/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte b/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte index bae077b9b8..adf82bb0f4 100644 --- a/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte @@ -265,7 +265,7 @@ {/each} {:else}
- {$i18n.t('No files found.')} + {$i18n.t('File not found.')}
{/if}
--> diff --git a/src/lib/components/chat/MessageInput/InputMenu.svelte b/src/lib/components/chat/MessageInput/InputMenu.svelte index 27fe2cde29..6a024af512 100644 --- a/src/lib/components/chat/MessageInput/InputMenu.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu.svelte @@ -33,6 +33,7 @@ let tools = {}; let show = false; + let showAllTools = false; $: if (show) { init(); @@ -102,7 +103,7 @@ transition={flyAndScale} > {#if Object.keys(tools).length > 0} -
+
{#each Object.keys(tools) as toolId}
- + {#if Object.keys(tools).length > 3} + + {/if}
{/if} { - uploadOneDriveHandler(); - }} - > - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{$i18n.t('Microsoft OneDrive')}
+ + + { + uploadOneDriveHandler('personal'); + }} > - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{$i18n.t('OneDrive')}
-
+
{$i18n.t('Microsoft OneDrive (personal)')}
+
+ { + uploadOneDriveHandler('organizations'); + }} + > +
+
{$i18n.t('Microsoft OneDrive (work/school)')}
+
Includes SharePoint
+
+
+ + {/if}
diff --git a/src/lib/components/chat/MessageInput/VoiceRecording.svelte b/src/lib/components/chat/MessageInput/VoiceRecording.svelte index 70472055fb..d9bddf022d 100644 --- a/src/lib/components/chat/MessageInput/VoiceRecording.svelte +++ b/src/lib/components/chat/MessageInput/VoiceRecording.svelte @@ -1,18 +1,26 @@ {#key id} - { - dispatch('update', e.detail); - }} - on:code={(e) => { - dispatch('code', e.detail); - }} - /> + {/key} diff --git a/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte b/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte new file mode 100644 index 0000000000..8e12f4bf03 --- /dev/null +++ b/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte @@ -0,0 +1,81 @@ + + +{#if token.type === 'html'} + {#if html && html.includes(']*>([\s\S]*?)<\/video>/)} + {@const videoSrc = video && video[1]} + {#if videoSrc} + + + {:else} + {token.text} + {/if} + {:else if token.text && token.text.match(/]*src="https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?[^"]*)?"[^>]*><\/iframe>/)} + {@const match = token.text.match( + /]*src="https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?[^"]*)?"[^>]*><\/iframe>/ + )} + {@const ytId = match && match[1]} + {#if ytId} + + {/if} + {:else if token.text.includes(` + {/if} + {:else if token.text.includes(` + {:else} + {token.text} + {/if} +{/if} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte index 7693e4cb43..4ca9721363 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte @@ -13,6 +13,7 @@ import Image from '$lib/components/common/Image.svelte'; import KatexRenderer from './KatexRenderer.svelte'; import Source from './Source.svelte'; + import HtmlToken from './HTMLToken.svelte'; export let id: string; export let tokens: Token[]; @@ -23,16 +24,7 @@ {#if token.type === 'escape'} {unescapeHtml(token.text)} {:else if token.type === 'html'} - {@const html = DOMPurify.sanitize(token.text)} - {#if html && html.includes(' diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte index 790cf5be97..9f80fbc77b 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte @@ -1,6 +1,6 @@ @@ -238,10 +240,9 @@ messageChildrenIds = history.messages[currentMessageId].childrenIds; } history.currentId = currentMessageId; - - await tick(); - await updateChat(); - triggerScroll(); + // await tick(); + // await updateChat(); + // triggerScroll(); } }} > @@ -293,7 +294,7 @@
- Merged Response + {$i18n.t('Merged Response')} {#if message.timestamp} { const messageContent = postprocessAfterEditing(editedContent ? editedContent : ''); - editMessage(message.id, messageContent, false); + editMessage(message.id, { content: messageContent }, false); edit = false; editedContent = ''; @@ -388,7 +388,7 @@ const saveAsCopyHandler = async () => { const messageContent = postprocessAfterEditing(editedContent ? editedContent : ''); - editMessage(message.id, messageContent); + editMessage(message.id, { content: messageContent }); edit = false; editedContent = ''; @@ -623,12 +623,6 @@ ).at(-1)} {#if !status?.hidden}
- {#if status?.done === false} -
- -
- {/if} - {#if status?.action === 'web_search' && status?.urls}
@@ -683,6 +677,8 @@ {$i18n.t('No search query generated')} {:else if status?.description === 'Generating search query'} {$i18n.t('Generating search query')} + {:else if status?.description === 'Searching the web'} + {$i18n.t('Searching the web...')} {:else} {status?.description} {/if} @@ -777,7 +773,7 @@
{:else}
- {#if message.content === '' && !message.error} + {#if message.content === '' && !message.error && (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length === 0} {:else if message.content && message.error !== true} diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index 605ab63523..4a61b7190d 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -43,6 +43,8 @@ let edit = false; let editedContent = ''; + let editedFiles = []; + let messageEditTextAreaElement: HTMLTextAreaElement; let message = JSON.parse(JSON.stringify(history.messages[messageId])); @@ -62,25 +64,30 @@ const editMessageHandler = async () => { edit = true; editedContent = message.content; + editedFiles = message.files; await tick(); - messageEditTextAreaElement.style.height = ''; - messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`; + if (messageEditTextAreaElement) { + messageEditTextAreaElement.style.height = ''; + messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`; - messageEditTextAreaElement?.focus(); + messageEditTextAreaElement?.focus(); + } }; const editMessageConfirmHandler = async (submit = true) => { - editMessage(message.id, editedContent, submit); + editMessage(message.id, { content: editedContent, files: editedFiles }, submit); edit = false; editedContent = ''; + editedFiles = []; }; const cancelEditMessage = () => { edit = false; editedContent = ''; + editedFiles = []; }; const deleteMessageHandler = async () => { @@ -139,30 +146,90 @@ {/if}
- {#if message.files} -
- {#each message.files as file} -
- {#if file.type === 'image'} - - {:else} - - {/if} -
- {/each} -
+ {#if edit !== true} + {#if message.files} +
+ {#each message.files as file} +
+ {#if file.type === 'image'} + + {:else} + + {/if} +
+ {/each} +
+ {/if} {/if} {#if message.content !== ''} {#if edit === true}
+ {#if (editedFiles ?? []).length > 0} +
+ {#each editedFiles as file, fileIdx} + {#if file.type === 'image'} +
+
+ input +
+
+ +
+
+ {:else} + { + editedFiles.splice(fileIdx, 1); + + editedFiles = editedFiles; + }} + on:click={() => { + console.log(file); + }} + /> + {/if} + {/each} +
+ {/if} +