diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e3e2be4d..4d119a1386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,106 @@ 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.36] - 2025-11-07 + +### Added + +- 🔐 OAuth group parsing now supports configurable separators via the "OAUTH_GROUPS_SEPARATOR" environment variable, enabling proper handling of semicolon-separated group claims from providers like CILogon. [#18987](https://github.com/open-webui/open-webui/pull/18987), [#18979](https://github.com/open-webui/open-webui/issues/18979) + +### Fixed + +- 🛠️ Tool calling functionality is restored by correcting asynchronous function handling in tool parameter updates. [#18981](https://github.com/open-webui/open-webui/issues/18981) +- 🖼️ The ComfyUI image edit workflow editor modal now opens correctly when clicking the Edit button. [#18978](https://github.com/open-webui/open-webui/issues/18978) +- 🔥 Firecrawl import errors are resolved by implementing lazy loading and using the correct class name. [#18973](https://github.com/open-webui/open-webui/issues/18973) +- 🔌 Socket.IO CORS warning is resolved by properly configuring CORS origins for Socket.IO connections. [Commit](https://github.com/open-webui/open-webui/commit/639d26252e528c9c37a5f553b11eb94376d8792d) + +## [0.6.35] - 2025-11-06 + +### Added + +- 🖼️ Image generation system received a comprehensive overhaul with major new capabilities including full image editing support allowing users to modify existing images using text prompts with OpenAI, Gemini, or ComfyUI engines, adding Gemini 2.5 Flash Image (Nano Banana) support, Qwen Image Edit integration, resolution of base64-encoded image display issues, streamlined AUTOMATIC1111 configuration by consolidating parameters into a flexible JSON parameters field, and enhanced UI with a code editor modal for ComfyUI workflow management. [#17434](https://github.com/open-webui/open-webui/pull/17434), [#16976](https://github.com/open-webui/open-webui/issues/16976), [Commit](https://github.com/open-webui/open-webui/commit/8e5690aab4f632a57027e2acf880b8f89a8717c0), [Commit](https://github.com/open-webui/open-webui/commit/72f8539fd2e679fec0762945f22f4b8a6920afa0), [Commit](https://github.com/open-webui/open-webui/commit/8d34fcb586eeee1fac6da2f991518b8a68b00b72), [Commit](https://github.com/open-webui/open-webui/commit/72900cd686de1fa6be84b5a8a2fc857cff7b91b8) +- 🔒 CORS origin validation was added to WebSocket connections as a defense-in-depth security measure against cross-site WebSocket hijacking attacks. [#18411](https://github.com/open-webui/open-webui/pull/18411), [#18410](https://github.com/open-webui/open-webui/issues/18410) +- 🔄 Automatic page refresh now occurs when a version update is detected via WebSocket connection, ensuring users always run the latest version without cache issues. [Commit](https://github.com/open-webui/open-webui/commit/989f192c92d2fe55daa31336e7971e21798b96ae) +- 🐍 Experimental initial preparations for Python 3.13 compatibility by updating dependencies with security enhancements and cryptographic improvements. [#18430](https://github.com/open-webui/open-webui/pull/18430), [#18424](https://github.com/open-webui/open-webui/pull/18424) +- ⚡ Image compression now preserves the original image format instead of converting to PNG, significantly reducing file sizes and improving chat loading performance. [#18506](https://github.com/open-webui/open-webui/pull/18506) +- 🎤 Mistral Voxtral model support was added for text-to-speech, including voxtral-small and voxtral-mini models with both transcription and chat completion API support. [#18934](https://github.com/open-webui/open-webui/pull/18934) +- 🔊 Text-to-speech now uses a global audio queue system to prevent overlapping playback, ensuring only one TTS instance plays at a time with proper stop/start controls and automatic cleanup when switching between messages. [#16152](https://github.com/open-webui/open-webui/pull/16152), [#18744](https://github.com/open-webui/open-webui/pull/18744), [#16150](https://github.com/open-webui/open-webui/issues/16150) +- 🔊 ELEVENLABS_API_BASE_URL environment variable now allows configuration of custom ElevenLabs API endpoints, enabling support for EU residency API requirements. [#18402](https://github.com/open-webui/open-webui/issues/18402) +- 🔐 OAUTH_ROLES_SEPARATOR environment variable now allows custom role separators for OAuth roles that contain commas, useful for roles specified in LDAP syntax. [#18572](https://github.com/open-webui/open-webui/pull/18572) +- 📄 External document loaders can now optionally forward user information headers when ENABLE_FORWARD_USER_INFO_HEADERS is enabled, enabling cost tracking, audit logs, and usage analytics for external services. [#18731](https://github.com/open-webui/open-webui/pull/18731) +- 📄 MISTRAL_OCR_API_BASE_URL environment variable now allows configuration of custom Mistral OCR API endpoints for flexible deployment options. [Commit](https://github.com/open-webui/open-webui/commit/415b93c7c35c2e2db4425e6da1b88b3750f496b0) +- ⌨️ Keyboard shortcut hints are now displayed on sidebar buttons with a refactored shortcuts modal that accurately reflects all available hotkeys across different keyboard layouts. [#18473](https://github.com/open-webui/open-webui/pull/18473) +- 🛠️ Tooltips now display tool descriptions when hovering over tool names on the model edit page, improving usability and providing immediate context. [#18707](https://github.com/open-webui/open-webui/pull/18707) +- 📝 "Create a new note" from the search modal now immediately creates a new private note and opens it in the editor instead of navigating to the generic notes page. [#18255](https://github.com/open-webui/open-webui/pull/18255) +- 🖨️ Code block output now preserves whitespace formatting with monospace font to accurately reflect terminal behavior. [#18352](https://github.com/open-webui/open-webui/pull/18352) +- ✏️ Edit button is now available in the three-dot menu of models in the workspace section for quick access to model editing, with the menu reorganized for better user experience and Edit, Clone, Copy Link, and Share options logically grouped. [#18574](https://github.com/open-webui/open-webui/pull/18574) +- 📌 Sidebar models section is now collapsible, allowing users to expand and collapse the pinned models list for better sidebar organization. [Commit](https://github.com/open-webui/open-webui/commit/82c08a3b5d189f81c96b6548cc872198771015b0) +- 🌙 Dark mode styles for select elements were added using Tailwind CSS classes, improving consistency across the interface. [#18636](https://github.com/open-webui/open-webui/pull/18636) +- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security. +- 🌐 Translations for Portuguese (Brazil), Greek, German, Traditional Chinese, Simplified Chinese, Spanish, Georgian, Danish, and Estonian were enhanced and expanded. + +### Fixed + +- 🔒 Server-Sent Event (SSE) code injection vulnerability in Direct Connections is resolved by blocking event emission from untrusted external model servers; event emitters from direct connected model servers are no longer supported, preventing arbitrary JavaScript execution in user browsers. [Commit](https://github.com/open-webui/open-webui/commit/8af6a4cf21b756a66cd58378a01c60f74c39b7ca) +- 🛡️ DOM XSS vulnerability in "Insert Prompt as Rich Text" is resolved by sanitizing HTML content with DOMPurify before rendering. [Commit](https://github.com/open-webui/open-webui/commit/eb9c4c0e358c274aea35f21c2856c0a20051e5f1) +- ⚙️ MCP server cancellation scope corruption is prevented by reversing disconnection order to follow LIFO and properly handling exceptions, resolving 100% CPU usage when resuming chats with expired tokens or using multiple streamable MCP servers. [#18537](https://github.com/open-webui/open-webui/pull/18537) +- 🔧 UI freeze when querying models with knowledge bases containing inconsistent distance metrics is resolved by properly initializing the distances array in citations. [#18585](https://github.com/open-webui/open-webui/pull/18585) +- 🤖 Duplicate model IDs from multiple OpenAI endpoints are now automatically deduplicated server-side, preventing frontend crashes for users with unified gateway proxies that aggregate multiple providers. [Commit](https://github.com/open-webui/open-webui/commit/fdf7ca11d4f3cc8fe63e81c98dc0d1e48e52ba36) +- 🔐 Login failures with passwords longer than 72 bytes are resolved by safely truncating oversized passwords for bcrypt compatibility. [#18157](https://github.com/open-webui/open-webui/issues/18157) +- 🔐 OAuth 2.1 MCP tool connections now automatically re-register clients when stored client IDs become stale, preventing unauthorized_client errors after editing tool endpoints and providing detailed error messages for callback failures. [#18415](https://github.com/open-webui/open-webui/pull/18415), [#18309](https://github.com/open-webui/open-webui/issues/18309) +- 🔓 OAuth 2.1 discovery, metadata fetching, and dynamic client registration now correctly use HTTP proxy environment variables when trust_env is enabled. [Commit](https://github.com/open-webui/open-webui/commit/bafeb76c411483bd6b135f0edbcdce048120f264) +- 🔌 MCP server connection failures now display clear error messages in the chat interface instead of silently failing. [#18892](https://github.com/open-webui/open-webui/pull/18892), [#18889](https://github.com/open-webui/open-webui/issues/18889) +- 💬 Chat titles are now properly generated even when title auto-generation is disabled in interface settings, fixing an issue where chats would remain labeled as "New chat". [#18761](https://github.com/open-webui/open-webui/pull/18761), [#18717](https://github.com/open-webui/open-webui/issues/18717), [#6478](https://github.com/open-webui/open-webui/issues/6478) +- 🔍 Chat query errors are prevented by properly validating and handling the "order_by" parameter to ensure requested columns exist. [#18400](https://github.com/open-webui/open-webui/pull/18400), [#18452](https://github.com/open-webui/open-webui/pull/18452) +- 🔧 Root-level max_tokens parameter is no longer dropped when proxying to Ollama, properly converting to num_predict to limit output token length as intended. [#18618](https://github.com/open-webui/open-webui/issues/18618) +- 🔑 Self-hosted Marker instances can now be used without requiring an API key, while keeping it optional for datalab Marker service users. [#18617](https://github.com/open-webui/open-webui/issues/18617) +- 🔧 OpenAPI specification endpoint conflict between "/api/v1/models" and "/api/v1/models/" is resolved by changing the models router endpoint to "/list", preventing duplicate operationId errors when generating TypeScript API clients. [#18758](https://github.com/open-webui/open-webui/issues/18758) +- 🏷️ Model tags are now de-duplicated case-insensitively in both the model selector and workspace models page, preventing duplicate entries with different capitalization from appearing in filter dropdowns. [#18716](https://github.com/open-webui/open-webui/pull/18716), [#18711](https://github.com/open-webui/open-webui/issues/18711) +- 📄 Docling RAG parameter configuration is now correctly saved in the admin UI by fixing the typo in the "DOCLING_PARAMS" parameter name. [#18390](https://github.com/open-webui/open-webui/pull/18390) +- 📃 Tika document processing now automatically detects content types instead of relying on potentially incorrect browser-provided mime-types, improving file handling accuracy for formats like RTF. [#18765](https://github.com/open-webui/open-webui/pull/18765), [#18683](https://github.com/open-webui/open-webui/issues/18683) +- 🖼️ Image and video uploads to knowledge bases now display proper error messages instead of showing an infinite spinner when the content extraction engine does not support these file types. [#18514](https://github.com/open-webui/open-webui/issues/18514) +- 📝 Notes PDF export now properly detects and applies dark mode styling consistently across both the notes list and individual note pages, with a shared utility function to eliminate code duplication. [#18526](https://github.com/open-webui/open-webui/issues/18526) +- 💭 Details tags for reasoning content are now correctly identified and rendered even when the same tag is present in user messages. [#18840](https://github.com/open-webui/open-webui/pull/18840), [#18294](https://github.com/open-webui/open-webui/issues/18294) +- 📊 Mermaid and Vega rendering errors now display inline with the code instead of showing repetitive toast notifications, improving user experience when models generate invalid diagram syntax. [Commit](https://github.com/open-webui/open-webui/commit/fdc0f04a8b7dd0bc9f9dc0e7e30854f7a0eea3e9) +- 📈 Mermaid diagram rendering errors no longer cause UI unavailability or display error messages below the input box. [#18493](https://github.com/open-webui/open-webui/pull/18493), [#18340](https://github.com/open-webui/open-webui/issues/18340) +- 🔗 Web search SSL verification is now asynchronous, preventing the website from hanging during web search operations. [#18714](https://github.com/open-webui/open-webui/pull/18714), [#18699](https://github.com/open-webui/open-webui/issues/18699) +- 🌍 Web search results now correctly use HTTP proxy environment variables when WEB_SEARCH_TRUST_ENV is enabled. [#18667](https://github.com/open-webui/open-webui/pull/18667), [#7008](https://github.com/open-webui/open-webui/discussions/7008) +- 🔍 Google Programmable Search Engine now properly includes referer headers, enabling API keys with HTTP referrer restrictions configured in Google Cloud Console. [#18871](https://github.com/open-webui/open-webui/pull/18871), [#18870](https://github.com/open-webui/open-webui/issues/18870) +- ⚡ YouTube video transcript fetching now works correctly when using a proxy connection. [#18419](https://github.com/open-webui/open-webui/pull/18419) +- 🎙️ Speech-to-text transcription no longer deletes or replaces existing text in the prompt input field, properly preserving any previously entered content. [#18540](https://github.com/open-webui/open-webui/issues/18540) +- 🎙️ The "Instant Auto-Send After Voice Transcription" setting now functions correctly and automatically sends transcribed text when enabled. [#18466](https://github.com/open-webui/open-webui/issues/18466) +- ⚙️ Chat settings now load properly when reopening a tab or starting a new session by initializing defaults when sessionStorage is empty. [#18438](https://github.com/open-webui/open-webui/pull/18438) +- 🔎 Folder tag search in the sidebar now correctly handles folder names with multiple spaces by replacing all spaces with underscores. [Commit](https://github.com/open-webui/open-webui/commit/a8fe979af68e47e4e4bb3eb76e48d93d60cd2a45) +- 🛠️ Functions page now updates immediately after deleting a function, removing the need for a manual page reload. [#18912](https://github.com/open-webui/open-webui/pull/18912), [#18908](https://github.com/open-webui/open-webui/issues/18908) +- 🛠️ Native tool calling now properly supports sequential tool calls with shared context, allowing tools to access images and data from previous tool executions in the same conversation. [#18664](https://github.com/open-webui/open-webui/pull/18664) +- 🎯 Globally enabled actions in the model editor now correctly apply as global instead of being treated as disabled. [#18577](https://github.com/open-webui/open-webui/pull/18577) +- 📋 Clipboard images pasted via the "{{CLIPBOARD}}" prompt variable are now correctly converted to base64 format before being sent to the backend, resolving base64 encoding errors. [#18432](https://github.com/open-webui/open-webui/pull/18432), [#18425](https://github.com/open-webui/open-webui/issues/18425) +- 📋 File list is now cleared when switching to models that do not support file uploads, preventing files from being sent to incompatible models. [#18496](https://github.com/open-webui/open-webui/pull/18496) +- 📂 Move menu no longer displays when folders are empty. [#18484](https://github.com/open-webui/open-webui/pull/18484) +- 📁 Folder and channel creation now validates that names are not empty, preventing creation of folders or channels with no name and showing an error toast if attempted. [#18564](https://github.com/open-webui/open-webui/pull/18564) +- 🖊️ Rich text input no longer removes text between equals signs when pasting code with comparison operators. [#18551](https://github.com/open-webui/open-webui/issues/18551) +- ⌨️ Keyboard shortcuts now display the correct keys for international and non-QWERTY keyboard layouts by detecting the user's layout using the Keyboard API. [#18533](https://github.com/open-webui/open-webui/pull/18533) +- 🌐 "Attach Webpage" button now displays with correct disabled styling when a model does not support file uploads. [#18483](https://github.com/open-webui/open-webui/pull/18483) +- 🎚️ Divider no longer displays in the integrations menu when no integrations are enabled. [#18487](https://github.com/open-webui/open-webui/pull/18487) +- 📱 Chat controls button is now properly hidden on mobile for users without admin or explicit chat control permissions. [#18641](https://github.com/open-webui/open-webui/pull/18641) +- 📍 User menu, download submenu, and move submenu are now repositioned to prevent overlap with the Chat Controls sidebar when it is open. [Commit](https://github.com/open-webui/open-webui/commit/414ab51cb6df1ab0d6c85ac6c1f2c5c9a5f8e2aa) +- 🎯 Artifacts button no longer appears in the chat menu when there are no artifacts to display. [Commit](https://github.com/open-webui/open-webui/commit/ed6449d35f84f68dc75ee5c6b3f4748a3fda0096) +- 🎨 Artifacts view now automatically displays when opening an existing conversation containing artifacts, improving user experience. [#18215](https://github.com/open-webui/open-webui/pull/18215) +- 🖌️ Formatting toolbar is no longer hidden under images or code blocks in chat and now displays correctly above all message content. +- 🎨 Layout shift near system instructions is prevented by properly rendering the chat component when system prompts are empty. [#18594](https://github.com/open-webui/open-webui/pull/18594) +- 📐 Modal layout shift caused by scrollbar appearance is prevented by adding a stable scrollbar gutter. [#18591](https://github.com/open-webui/open-webui/pull/18591) +- ✨ Spacing between icon and label in the user menu dropdown items is now consistent. [#18595](https://github.com/open-webui/open-webui/pull/18595) +- 💬 Duplicate prompt suggestions no longer cause the webpage to freeze or throw JavaScript errors by implementing proper key management with composite keys. [#18841](https://github.com/open-webui/open-webui/pull/18841), [#18566](https://github.com/open-webui/open-webui/issues/18566) +- 🔍 Chat preview loading in the search modal now works correctly for all search results by fixing an index boundary check that previously caused out-of-bounds errors. [#18911](https://github.com/open-webui/open-webui/pull/18911) +- ♿ Screen reader support was enhanced by wrapping messages in semantic elements with descriptive aria-labels, adding "Assistant is typing" and "Response complete" announcements for improved accessibility. [#18735](https://github.com/open-webui/open-webui/pull/18735) +- 🔒 Incorrect await call in the OAuth 2.1 flow is removed, eliminating a logged exception during authentication. [#18236](https://github.com/open-webui/open-webui/pull/18236) +- 🛡️ Duplicate crossorigin attribute in the manifest file was removed. [#18413](https://github.com/open-webui/open-webui/pull/18413) + +### Changed + +- 🔄 Firecrawl integration was refactored to use the official Firecrawl SDK instead of direct HTTP requests and langchain_community FireCrawlLoader, improving reliability and performance with batch scraping support and enhanced error handling. [#18635](https://github.com/open-webui/open-webui/pull/18635) +- 📄 MinerU content extraction engine now only supports PDF files following the upstream removal of LibreOffice document conversion in version 2.0.0; users needing to process office documents should convert them to PDF format first. [#18448](https://github.com/open-webui/open-webui/issues/18448) + ## [0.6.34] - 2025-10-16 ### Added diff --git a/CHANGELOG_EXTRA.md b/CHANGELOG_EXTRA.md index a17ebc2566..fc5536c684 100644 --- a/CHANGELOG_EXTRA.md +++ b/CHANGELOG_EXTRA.md @@ -5,6 +5,12 @@ 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.36.1] - 2025.11.07 + +### Changed + +- 合并官方 0.6.36 改动 + ## [0.6.34.1] - 2025.10.17 ### Changed diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index a146e2a450..b3a466e63d 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -620,25 +620,34 @@ def __getattr__(self, key): os.environ.get("OAUTH_BLOCKED_GROUPS", "[]"), ) +OAUTH_GROUPS_SEPARATOR = os.environ.get("OAUTH_GROUPS_SEPARATOR", ";") + OAUTH_ROLES_CLAIM = PersistentConfig( "OAUTH_ROLES_CLAIM", "oauth.roles_claim", os.environ.get("OAUTH_ROLES_CLAIM", "roles"), ) +SEP = os.environ.get("OAUTH_ROLES_SEPARATOR", ",") + OAUTH_ALLOWED_ROLES = PersistentConfig( "OAUTH_ALLOWED_ROLES", "oauth.allowed_roles", [ role.strip() - for role in os.environ.get("OAUTH_ALLOWED_ROLES", "user,admin").split(",") + for role in os.environ.get("OAUTH_ALLOWED_ROLES", f"user{SEP}admin").split(SEP) + if role ], ) OAUTH_ADMIN_ROLES = PersistentConfig( "OAUTH_ADMIN_ROLES", "oauth.admin_roles", - [role.strip() for role in os.environ.get("OAUTH_ADMIN_ROLES", "admin").split(",")], + [ + role.strip() + for role in os.environ.get("OAUTH_ADMIN_ROLES", "admin").split(SEP) + if role + ], ) OAUTH_ALLOWED_DOMAINS = PersistentConfig( @@ -2472,6 +2481,12 @@ class BannerModel(BaseModel): os.getenv("DOCUMENT_INTELLIGENCE_KEY", ""), ) +MISTRAL_OCR_API_BASE_URL = PersistentConfig( + "MISTRAL_OCR_API_BASE_URL", + "rag.MISTRAL_OCR_API_BASE_URL", + os.getenv("MISTRAL_OCR_API_BASE_URL", "https://api.mistral.ai/v1"), +) + MISTRAL_OCR_API_KEY = PersistentConfig( "MISTRAL_OCR_API_KEY", "rag.mistral_ocr_api_key", @@ -3068,16 +3083,30 @@ class BannerModel(BaseModel): # Images #################################### +ENABLE_IMAGE_GENERATION = PersistentConfig( + "ENABLE_IMAGE_GENERATION", + "image_generation.enable", + os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true", +) + IMAGE_GENERATION_ENGINE = PersistentConfig( "IMAGE_GENERATION_ENGINE", "image_generation.engine", os.getenv("IMAGE_GENERATION_ENGINE", "openai"), ) -ENABLE_IMAGE_GENERATION = PersistentConfig( - "ENABLE_IMAGE_GENERATION", - "image_generation.enable", - os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true", +IMAGE_GENERATION_MODEL = PersistentConfig( + "IMAGE_GENERATION_MODEL", + "image_generation.model", + os.getenv("IMAGE_GENERATION_MODEL", ""), +) + +IMAGE_SIZE = PersistentConfig( + "IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512") +) + +IMAGE_STEPS = PersistentConfig( + "IMAGE_STEPS", "image_generation.steps", int(os.getenv("IMAGE_STEPS", 50)) ) ENABLE_IMAGE_PROMPT_GENERATION = PersistentConfig( @@ -3097,34 +3126,17 @@ class BannerModel(BaseModel): os.getenv("AUTOMATIC1111_API_AUTH", ""), ) -AUTOMATIC1111_CFG_SCALE = PersistentConfig( - "AUTOMATIC1111_CFG_SCALE", - "image_generation.automatic1111.cfg_scale", - ( - float(os.environ.get("AUTOMATIC1111_CFG_SCALE")) - if os.environ.get("AUTOMATIC1111_CFG_SCALE") - else None - ), -) +automatic1111_params = os.getenv("AUTOMATIC1111_PARAMS", "") +try: + automatic1111_params = json.loads(automatic1111_params) +except json.JSONDecodeError: + automatic1111_params = {} -AUTOMATIC1111_SAMPLER = PersistentConfig( - "AUTOMATIC1111_SAMPLER", - "image_generation.automatic1111.sampler", - ( - os.environ.get("AUTOMATIC1111_SAMPLER") - if os.environ.get("AUTOMATIC1111_SAMPLER") - else None - ), -) -AUTOMATIC1111_SCHEDULER = PersistentConfig( - "AUTOMATIC1111_SCHEDULER", - "image_generation.automatic1111.scheduler", - ( - os.environ.get("AUTOMATIC1111_SCHEDULER") - if os.environ.get("AUTOMATIC1111_SCHEDULER") - else None - ), +AUTOMATIC1111_PARAMS = PersistentConfig( + "AUTOMATIC1111_PARAMS", + "image_generation.automatic1111.api_auth", + automatic1111_params, ) COMFYUI_BASE_URL = PersistentConfig( @@ -3289,18 +3301,79 @@ class BannerModel(BaseModel): os.getenv("IMAGES_GEMINI_API_KEY", GEMINI_API_KEY), ) -IMAGE_SIZE = PersistentConfig( - "IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512") +IMAGES_GEMINI_ENDPOINT_METHOD = PersistentConfig( + "IMAGES_GEMINI_ENDPOINT_METHOD", + "image_generation.gemini.endpoint_method", + os.getenv("IMAGES_GEMINI_ENDPOINT_METHOD", ""), ) -IMAGE_STEPS = PersistentConfig( - "IMAGE_STEPS", "image_generation.steps", int(os.getenv("IMAGE_STEPS", 50)) + +IMAGE_EDIT_ENGINE = PersistentConfig( + "IMAGE_EDIT_ENGINE", + "images.edit.engine", + os.getenv("IMAGE_EDIT_ENGINE", "openai"), ) -IMAGE_GENERATION_MODEL = PersistentConfig( - "IMAGE_GENERATION_MODEL", - "image_generation.model", - os.getenv("IMAGE_GENERATION_MODEL", ""), +IMAGE_EDIT_MODEL = PersistentConfig( + "IMAGE_EDIT_MODEL", + "images.edit.model", + os.getenv("IMAGE_EDIT_MODEL", ""), +) + +IMAGE_EDIT_SIZE = PersistentConfig( + "IMAGE_EDIT_SIZE", "images.edit.size", os.getenv("IMAGE_EDIT_SIZE", "") +) + +IMAGES_EDIT_OPENAI_API_BASE_URL = PersistentConfig( + "IMAGES_EDIT_OPENAI_API_BASE_URL", + "images.edit.openai.api_base_url", + os.getenv("IMAGES_EDIT_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), +) +IMAGES_EDIT_OPENAI_API_VERSION = PersistentConfig( + "IMAGES_EDIT_OPENAI_API_VERSION", + "images.edit.openai.api_version", + os.getenv("IMAGES_EDIT_OPENAI_API_VERSION", ""), +) + +IMAGES_EDIT_OPENAI_API_KEY = PersistentConfig( + "IMAGES_EDIT_OPENAI_API_KEY", + "images.edit.openai.api_key", + os.getenv("IMAGES_EDIT_OPENAI_API_KEY", OPENAI_API_KEY), +) + +IMAGES_EDIT_GEMINI_API_BASE_URL = PersistentConfig( + "IMAGES_EDIT_GEMINI_API_BASE_URL", + "images.edit.gemini.api_base_url", + os.getenv("IMAGES_EDIT_GEMINI_API_BASE_URL", GEMINI_API_BASE_URL), +) +IMAGES_EDIT_GEMINI_API_KEY = PersistentConfig( + "IMAGES_EDIT_GEMINI_API_KEY", + "images.edit.gemini.api_key", + os.getenv("IMAGES_EDIT_GEMINI_API_KEY", GEMINI_API_KEY), +) + + +IMAGES_EDIT_COMFYUI_BASE_URL = PersistentConfig( + "IMAGES_EDIT_COMFYUI_BASE_URL", + "images.edit.comfyui.base_url", + os.getenv("IMAGES_EDIT_COMFYUI_BASE_URL", ""), +) +IMAGES_EDIT_COMFYUI_API_KEY = PersistentConfig( + "IMAGES_EDIT_COMFYUI_API_KEY", + "images.edit.comfyui.api_key", + os.getenv("IMAGES_EDIT_COMFYUI_API_KEY", ""), +) + +IMAGES_EDIT_COMFYUI_WORKFLOW = PersistentConfig( + "IMAGES_EDIT_COMFYUI_WORKFLOW", + "images.edit.comfyui.workflow", + os.getenv("IMAGES_EDIT_COMFYUI_WORKFLOW", ""), +) + +IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = PersistentConfig( + "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES", + "images.edit.comfyui.nodes", + [], ) #################################### @@ -3335,6 +3408,11 @@ class BannerModel(BaseModel): os.getenv("DEEPGRAM_API_KEY", ""), ) +# ElevenLabs configuration +ELEVENLABS_API_BASE_URL = os.getenv( + "ELEVENLABS_API_BASE_URL", "https://api.elevenlabs.io" +) + AUDIO_STT_OPENAI_API_BASE_URL = PersistentConfig( "AUDIO_STT_OPENAI_API_BASE_URL", "audio.stt.openai.api_base_url", @@ -3401,6 +3479,24 @@ class BannerModel(BaseModel): os.getenv("AUDIO_STT_AZURE_MAX_SPEAKERS", ""), ) +AUDIO_STT_MISTRAL_API_KEY = PersistentConfig( + "AUDIO_STT_MISTRAL_API_KEY", + "audio.stt.mistral.api_key", + os.getenv("AUDIO_STT_MISTRAL_API_KEY", ""), +) + +AUDIO_STT_MISTRAL_API_BASE_URL = PersistentConfig( + "AUDIO_STT_MISTRAL_API_BASE_URL", + "audio.stt.mistral.api_base_url", + os.getenv("AUDIO_STT_MISTRAL_API_BASE_URL", "https://api.mistral.ai/v1"), +) + +AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = PersistentConfig( + "AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS", + "audio.stt.mistral.use_chat_completions", + os.getenv("AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS", "false").lower() == "true", +) + AUDIO_TTS_OPENAI_API_BASE_URL = PersistentConfig( "AUDIO_TTS_OPENAI_API_BASE_URL", "audio.tts.openai.api_base_url", diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 98fb0ad1ff..364e9c87b2 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -140,9 +140,7 @@ # Image AUTOMATIC1111_API_AUTH, AUTOMATIC1111_BASE_URL, - AUTOMATIC1111_CFG_SCALE, - AUTOMATIC1111_SAMPLER, - AUTOMATIC1111_SCHEDULER, + AUTOMATIC1111_PARAMS, COMFYUI_BASE_URL, COMFYUI_API_KEY, COMFYUI_WORKFLOW, @@ -158,6 +156,19 @@ IMAGES_OPENAI_API_KEY, IMAGES_GEMINI_API_BASE_URL, IMAGES_GEMINI_API_KEY, + IMAGES_GEMINI_ENDPOINT_METHOD, + IMAGE_EDIT_ENGINE, + IMAGE_EDIT_MODEL, + IMAGE_EDIT_SIZE, + IMAGES_EDIT_OPENAI_API_BASE_URL, + IMAGES_EDIT_OPENAI_API_KEY, + IMAGES_EDIT_OPENAI_API_VERSION, + IMAGES_EDIT_GEMINI_API_BASE_URL, + IMAGES_EDIT_GEMINI_API_KEY, + IMAGES_EDIT_COMFYUI_BASE_URL, + IMAGES_EDIT_COMFYUI_API_KEY, + IMAGES_EDIT_COMFYUI_WORKFLOW, + IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, # Audio AUDIO_STT_ENGINE, AUDIO_STT_MODEL, @@ -169,6 +180,9 @@ AUDIO_STT_AZURE_LOCALES, AUDIO_STT_AZURE_BASE_URL, AUDIO_STT_AZURE_MAX_SPEAKERS, + AUDIO_STT_MISTRAL_API_KEY, + AUDIO_STT_MISTRAL_API_BASE_URL, + AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS, AUDIO_TTS_ENGINE, AUDIO_TTS_MODEL, AUDIO_TTS_VOICE, @@ -256,6 +270,7 @@ DOCLING_PICTURE_DESCRIPTION_API, DOCUMENT_INTELLIGENCE_ENDPOINT, DOCUMENT_INTELLIGENCE_KEY, + MISTRAL_OCR_API_BASE_URL, MISTRAL_OCR_API_KEY, RAG_TEXT_SPLITTER, TIKTOKEN_ENCODING_NAME, @@ -501,9 +516,11 @@ ) from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.oauth import ( + get_oauth_client_info_with_dynamic_client_registration, + encrypt_data, + decrypt_data, OAuthManager, OAuthClientManager, - decrypt_data, OAuthClientInformationFull, ) from open_webui.utils.security_headers import SecurityHeadersMiddleware @@ -877,6 +894,7 @@ async def lifespan(app: FastAPI): app.state.config.DOCLING_PICTURE_DESCRIPTION_API = DOCLING_PICTURE_DESCRIPTION_API app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY +app.state.config.MISTRAL_OCR_API_BASE_URL = MISTRAL_OCR_API_BASE_URL app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY app.state.config.MINERU_API_MODE = MINERU_API_MODE app.state.config.MINERU_API_URL = MINERU_API_URL @@ -1079,27 +1097,40 @@ async def lifespan(app: FastAPI): app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION +app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL +app.state.config.IMAGE_SIZE = IMAGE_SIZE +app.state.config.IMAGE_STEPS = IMAGE_STEPS + app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL app.state.config.IMAGES_OPENAI_API_VERSION = IMAGES_OPENAI_API_VERSION app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY app.state.config.IMAGES_GEMINI_API_BASE_URL = IMAGES_GEMINI_API_BASE_URL app.state.config.IMAGES_GEMINI_API_KEY = IMAGES_GEMINI_API_KEY - -app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL +app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD = IMAGES_GEMINI_ENDPOINT_METHOD app.state.config.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL app.state.config.AUTOMATIC1111_API_AUTH = AUTOMATIC1111_API_AUTH -app.state.config.AUTOMATIC1111_CFG_SCALE = AUTOMATIC1111_CFG_SCALE -app.state.config.AUTOMATIC1111_SAMPLER = AUTOMATIC1111_SAMPLER -app.state.config.AUTOMATIC1111_SCHEDULER = AUTOMATIC1111_SCHEDULER +app.state.config.AUTOMATIC1111_PARAMS = AUTOMATIC1111_PARAMS + app.state.config.COMFYUI_BASE_URL = COMFYUI_BASE_URL app.state.config.COMFYUI_API_KEY = COMFYUI_API_KEY app.state.config.COMFYUI_WORKFLOW = COMFYUI_WORKFLOW app.state.config.COMFYUI_WORKFLOW_NODES = COMFYUI_WORKFLOW_NODES -app.state.config.IMAGE_SIZE = IMAGE_SIZE -app.state.config.IMAGE_STEPS = IMAGE_STEPS + +app.state.config.IMAGE_EDIT_ENGINE = IMAGE_EDIT_ENGINE +app.state.config.IMAGE_EDIT_MODEL = IMAGE_EDIT_MODEL +app.state.config.IMAGE_EDIT_SIZE = IMAGE_EDIT_SIZE +app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL = IMAGES_EDIT_OPENAI_API_BASE_URL +app.state.config.IMAGES_EDIT_OPENAI_API_KEY = IMAGES_EDIT_OPENAI_API_KEY +app.state.config.IMAGES_EDIT_OPENAI_API_VERSION = IMAGES_EDIT_OPENAI_API_VERSION +app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL = IMAGES_EDIT_GEMINI_API_BASE_URL +app.state.config.IMAGES_EDIT_GEMINI_API_KEY = IMAGES_EDIT_GEMINI_API_KEY +app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL = IMAGES_EDIT_COMFYUI_BASE_URL +app.state.config.IMAGES_EDIT_COMFYUI_API_KEY = IMAGES_EDIT_COMFYUI_API_KEY +app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW = IMAGES_EDIT_COMFYUI_WORKFLOW +app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = IMAGES_EDIT_COMFYUI_WORKFLOW_NODES ######################################## # @@ -1124,6 +1155,12 @@ async def lifespan(app: FastAPI): 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.AUDIO_STT_MISTRAL_API_KEY = AUDIO_STT_MISTRAL_API_KEY +app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = AUDIO_STT_MISTRAL_API_BASE_URL +app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = ( + AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS +) + app.state.config.TTS_ENGINE = AUDIO_TTS_ENGINE app.state.config.TTS_MODEL = AUDIO_TTS_MODEL @@ -1625,11 +1662,15 @@ async def process_chat(request, form_data, user, metadata, model): log.info("Chat processing was cancelled") try: event_emitter = get_event_emitter(metadata) - await event_emitter( - {"type": "chat:tasks:cancel"}, + await asyncio.shield( + event_emitter( + {"type": "chat:tasks:cancel"}, + ) ) except Exception as e: pass + finally: + raise # re-raise to ensure proper task cancellation handling except Exception as e: log.debug(f"Error processing chat payload: {e}") if metadata.get("chat_id") and metadata.get("message_id"): @@ -1660,7 +1701,7 @@ async def process_chat(request, form_data, user, metadata, model): finally: try: if mcp_clients := metadata.get("mcp_clients"): - for client in mcp_clients.values(): + for client in reversed(mcp_clients.values()): await client.disconnect() except Exception as e: log.debug(f"Error cleaning up: {e}") @@ -2007,6 +2048,7 @@ async def get_current_usage(user=Depends(get_verified_user)): if tool_server_connection.get("type", "openapi") == "mcp": server_id = tool_server_connection.get("info", {}).get("id") auth_type = tool_server_connection.get("auth_type", "none") + if server_id and auth_type == "oauth_2.1": oauth_client_info = tool_server_connection.get("info", {}).get( "oauth_client_info", "" @@ -2052,6 +2094,64 @@ async def get_current_usage(user=Depends(get_verified_user)): ) +async def register_client(self, request, client_id: str) -> bool: + server_type, server_id = client_id.split(":", 1) + + connection = None + connection_idx = None + + for idx, conn in enumerate(request.app.state.config.TOOL_SERVER_CONNECTIONS or []): + if conn.get("type", "openapi") == server_type: + info = conn.get("info", {}) + if info.get("id") == server_id: + connection = conn + connection_idx = idx + break + + if connection is None or connection_idx is None: + log.warning( + f"Unable to locate MCP tool server configuration for client {client_id} during re-registration" + ) + return False + + server_url = connection.get("url") + oauth_server_key = (connection.get("config") or {}).get("oauth_server_key") + + try: + oauth_client_info = ( + await get_oauth_client_info_with_dynamic_client_registration( + request, + client_id, + server_url, + oauth_server_key, + ) + ) + except Exception as e: + log.error(f"Dynamic client re-registration failed for {client_id}: {e}") + return False + + try: + request.app.state.config.TOOL_SERVER_CONNECTIONS[connection_idx] = { + **connection, + "info": { + **connection.get("info", {}), + "oauth_client_info": encrypt_data( + oauth_client_info.model_dump(mode="json") + ), + }, + } + except Exception as e: + log.error( + f"Failed to persist updated OAuth client info for tool server {client_id}: {e}" + ) + return False + + oauth_client_manager.remove_client(client_id) + oauth_client_manager.add_client(client_id, oauth_client_info) + log.info(f"Re-registered OAuth client {client_id} for tool server") + return True + + @app.get("/oauth/clients/{client_id}/authorize") async def oauth_client_authorize( client_id: str, @@ -2059,6 +2159,41 @@ async def oauth_client_authorize( response: Response, user=Depends(get_verified_user), ): + # ensure_valid_client_registration + client = oauth_client_manager.get_client(client_id) + client_info = oauth_client_manager.get_client_info(client_id) + if client is None or client_info is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + if not await oauth_client_manager._preflight_authorization_url(client, client_info): + log.info( + "Detected invalid OAuth client %s; attempting re-registration", + client_id, + ) + + registered = await register_client(request, client_id) + if not registered: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to re-register OAuth client", + ) + + client = oauth_client_manager.get_client(client_id) + client_info = oauth_client_manager.get_client_info(client_id) + if client is None or client_info is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="OAuth client unavailable after re-registration", + ) + + if not await oauth_client_manager._preflight_authorization_url( + client, client_info + ): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="OAuth client registration is still invalid after re-registration", + ) + return await oauth_client_manager.handle_authorize(request, client_id=client_id) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index cfcbc004b7..c559932bcd 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -440,7 +440,10 @@ def get_archived_chat_list_by_user_id( order_by = filter.get("order_by") direction = filter.get("direction") - if order_by and direction and getattr(Chat, order_by): + if order_by and direction: + if not getattr(Chat, order_by, None): + raise ValueError("Invalid order_by field") + if direction.lower() == "asc": query = query.order_by(getattr(Chat, order_by).asc()) elif direction.lower() == "desc": @@ -762,15 +765,20 @@ def get_chats_by_user_id_and_search_text( ) elif dialect_name == "postgresql": - # PostgreSQL relies on proper JSON query for search + # PostgreSQL doesn't allow null bytes in text. We filter those out by checking + # the JSON representation for \u0000 before attempting text extraction postgres_content_sql = ( "EXISTS (" " SELECT 1 " " FROM json_array_elements(Chat.chat->'messages') AS message " - " WHERE LOWER(message->>'content') LIKE '%' || :content_key || '%'" + " WHERE message->'content' IS NOT NULL " + " AND (message->'content')::text NOT LIKE '%\\u0000%' " + " AND LOWER(message->>'content') LIKE '%' || :content_key || '%'" ")" ) postgres_content_clause = text(postgres_content_sql) + # Also filter out chats with null bytes in title + query = query.filter(text("Chat.title::text NOT LIKE '%\\x00%'")) query = query.filter( or_( Chat.title.ilike(bindparam("title_key")), diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index 81ce220384..b0e465dbe7 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -262,5 +262,16 @@ def delete_sessions_by_user_id(self, user_id: str) -> bool: log.error(f"Error deleting OAuth sessions by user ID: {e}") return False + def delete_sessions_by_provider(self, provider: str) -> bool: + """Delete all OAuth sessions for a provider""" + try: + with get_db() as db: + db.query(OAuthSession).filter_by(provider=provider).delete() + db.commit() + return True + except Exception as e: + log.error(f"Error deleting OAuth sessions by provider {provider}: {e}") + return False + OAuthSessions = OAuthSessionTable() diff --git a/backend/open_webui/retrieval/loaders/external_document.py b/backend/open_webui/retrieval/loaders/external_document.py index 1be2ca3f24..998afd36f6 100644 --- a/backend/open_webui/retrieval/loaders/external_document.py +++ b/backend/open_webui/retrieval/loaders/external_document.py @@ -5,6 +5,7 @@ from langchain_core.document_loaders import BaseLoader from langchain_core.documents import Document +from open_webui.utils.headers import include_user_info_headers from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) @@ -18,6 +19,7 @@ def __init__( url: str, api_key: str, mime_type=None, + user=None, **kwargs, ) -> None: self.url = url @@ -26,6 +28,8 @@ def __init__( self.file_path = file_path self.mime_type = mime_type + self.user = user + def load(self) -> List[Document]: with open(self.file_path, "rb") as f: data = f.read() @@ -42,6 +46,9 @@ def load(self) -> List[Document]: except: pass + if self.user is not None: + headers = include_user_info_headers(headers, self.user) + url = self.url if url.endswith("/"): url = url[:-1] diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index 2ef1d75e02..bbc3da9bc9 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -228,6 +228,7 @@ def load(self) -> list[Document]: class Loader: def __init__(self, engine: str = "", **kwargs): self.engine = engine + self.user = kwargs.get("user", None) self.kwargs = kwargs def load( @@ -264,6 +265,7 @@ def _get_loader(self, filename: str, file_content_type: str, file_path: str): url=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL"), api_key=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY"), mime_type=file_content_type, + user=self.user, ) elif self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"): if self._is_text_file(file_ext, file_content_type): @@ -272,7 +274,6 @@ def _get_loader(self, filename: str, file_content_type: str, file_path: str): loader = TikaLoader( 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 ( @@ -369,14 +370,8 @@ def _get_loader(self, filename: str, file_content_type: str, file_path: str): azure_credential=DefaultAzureCredential(), ) elif self.engine == "mineru" and file_ext in [ - "pdf", - "doc", - "docx", - "ppt", - "pptx", - "xls", - "xlsx", - ]: + "pdf" + ]: # MinerU currently only supports PDF loader = MinerULoader( file_path=file_path, api_mode=self.kwargs.get("MINERU_API_MODE", "local"), @@ -391,16 +386,9 @@ def _get_loader(self, filename: str, file_content_type: str, file_path: str): in ["pdf"] # Mistral OCR currently only supports PDF and images ): loader = MistralLoader( - api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path - ) - elif ( - self.engine == "external" - and self.kwargs.get("MISTRAL_OCR_API_KEY") != "" - and file_ext - in ["pdf"] # Mistral OCR currently only supports PDF and images - ): - loader = MistralLoader( - api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path + base_url=self.kwargs.get("MISTRAL_OCR_API_BASE_URL"), + api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), + file_path=file_path, ) else: if file_ext == "pdf": diff --git a/backend/open_webui/retrieval/loaders/mistral.py b/backend/open_webui/retrieval/loaders/mistral.py index b7f2622f5e..6a2d235559 100644 --- a/backend/open_webui/retrieval/loaders/mistral.py +++ b/backend/open_webui/retrieval/loaders/mistral.py @@ -30,10 +30,9 @@ class MistralLoader: - Enhanced error handling with retryable error classification """ - BASE_API_URL = "https://api.mistral.ai/v1" - def __init__( self, + base_url: str, api_key: str, file_path: str, timeout: int = 300, # 5 minutes default @@ -55,6 +54,9 @@ def __init__( if not os.path.exists(file_path): raise FileNotFoundError(f"File not found at {file_path}") + self.base_url = ( + base_url.rstrip("/") if base_url else "https://api.mistral.ai/v1" + ) self.api_key = api_key self.file_path = file_path self.timeout = timeout @@ -240,7 +242,7 @@ def _upload_file(self) -> str: in a context manager to minimize memory usage duration. """ log.info("Uploading file to Mistral API") - url = f"{self.BASE_API_URL}/files" + url = f"{self.base_url}/files" def upload_request(): # MEMORY OPTIMIZATION: Use context manager to minimize file handle lifetime @@ -275,7 +277,7 @@ def upload_request(): async def _upload_file_async(self, session: aiohttp.ClientSession) -> str: """Async file upload with streaming for better memory efficiency.""" - url = f"{self.BASE_API_URL}/files" + url = f"{self.base_url}/files" async def upload_request(): # Create multipart writer for streaming upload @@ -321,7 +323,7 @@ async def upload_request(): def _get_signed_url(self, file_id: str) -> str: """Retrieves a temporary signed URL for the uploaded file (sync version).""" log.info(f"Getting signed URL for file ID: {file_id}") - url = f"{self.BASE_API_URL}/files/{file_id}/url" + url = f"{self.base_url}/files/{file_id}/url" params = {"expiry": 1} signed_url_headers = {**self.headers, "Accept": "application/json"} @@ -346,7 +348,7 @@ async def _get_signed_url_async( self, session: aiohttp.ClientSession, file_id: str ) -> str: """Async signed URL retrieval.""" - url = f"{self.BASE_API_URL}/files/{file_id}/url" + url = f"{self.base_url}/files/{file_id}/url" params = {"expiry": 1} headers = {**self.headers, "Accept": "application/json"} @@ -373,7 +375,7 @@ async def url_request(): def _process_ocr(self, signed_url: str) -> Dict[str, Any]: """Sends the signed URL to the OCR endpoint for processing (sync version).""" log.info("Processing OCR via Mistral API") - url = f"{self.BASE_API_URL}/ocr" + url = f"{self.base_url}/ocr" ocr_headers = { **self.headers, "Content-Type": "application/json", @@ -407,7 +409,7 @@ async def _process_ocr_async( self, session: aiohttp.ClientSession, signed_url: str ) -> Dict[str, Any]: """Async OCR processing with timing metrics.""" - url = f"{self.BASE_API_URL}/ocr" + url = f"{self.base_url}/ocr" headers = { **self.headers, @@ -446,7 +448,7 @@ async def ocr_request(): def _delete_file(self, file_id: str) -> None: """Deletes the file from Mistral storage (sync version).""" log.info(f"Deleting uploaded file ID: {file_id}") - url = f"{self.BASE_API_URL}/files/{file_id}" + url = f"{self.base_url}/files/{file_id}" try: response = requests.delete( @@ -467,7 +469,7 @@ async def _delete_file_async( async def delete_request(): self._debug_log(f"Deleting file ID: {file_id}") async with session.delete( - url=f"{self.BASE_API_URL}/files/{file_id}", + url=f"{self.base_url}/files/{file_id}", headers=self.headers, timeout=aiohttp.ClientTimeout( total=self.cleanup_timeout diff --git a/backend/open_webui/retrieval/loaders/youtube.py b/backend/open_webui/retrieval/loaders/youtube.py index da17eaef65..cba602ed87 100644 --- a/backend/open_webui/retrieval/loaders/youtube.py +++ b/backend/open_webui/retrieval/loaders/youtube.py @@ -83,6 +83,7 @@ def load(self) -> List[Document]: TranscriptsDisabled, YouTubeTranscriptApi, ) + from youtube_transcript_api.proxies import GenericProxyConfig except ImportError: raise ImportError( 'Could not import "youtube_transcript_api" Python package. ' @@ -90,10 +91,9 @@ def load(self) -> List[Document]: ) if self.proxy_url: - youtube_proxies = { - "http": self.proxy_url, - "https": self.proxy_url, - } + youtube_proxies = GenericProxyConfig( + http_url=self.proxy_url, https_url=self.proxy_url + ) log.debug(f"Using proxy URL: {self.proxy_url[:14]}...") else: youtube_proxies = None diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 3470ead1d6..7db4208935 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -73,6 +73,7 @@ def get_loader(request, url: str): url, verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS, + trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV, ) @@ -670,46 +671,51 @@ def get_sources_from_items( collection_names.append(f"file-{item['id']}") elif item.get("type") == "collection": - if ( - item.get("context") == "full" - or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL - ): - # Manual Full Mode Toggle for Collection - knowledge_base = Knowledges.get_knowledge_by_id(item.get("id")) + # Manual Full Mode Toggle for Collection + knowledge_base = Knowledges.get_knowledge_by_id(item.get("id")) - if knowledge_base and ( - user.role == "admin" - or knowledge_base.user_id == user.id - or has_access(user.id, "read", knowledge_base.access_control) + if knowledge_base and ( + user.role == "admin" + or knowledge_base.user_id == user.id + or has_access(user.id, "read", knowledge_base.access_control) + ): + if ( + item.get("context") == "full" + or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL ): + if knowledge_base and ( + user.role == "admin" + or knowledge_base.user_id == user.id + or has_access(user.id, "read", knowledge_base.access_control) + ): + + file_ids = knowledge_base.data.get("file_ids", []) + + documents = [] + metadatas = [] + for file_id in file_ids: + file_object = Files.get_file_by_id(file_id) + + if file_object: + documents.append(file_object.data.get("content", "")) + metadatas.append( + { + "file_id": file_id, + "name": file_object.filename, + "source": file_object.filename, + } + ) - file_ids = knowledge_base.data.get("file_ids", []) - - documents = [] - metadatas = [] - for file_id in file_ids: - file_object = Files.get_file_by_id(file_id) - - if file_object: - documents.append(file_object.data.get("content", "")) - metadatas.append( - { - "file_id": file_id, - "name": file_object.filename, - "source": file_object.filename, - } - ) - - query_result = { - "documents": [documents], - "metadatas": [metadatas], - } - else: - # Fallback to collection names - if item.get("legacy"): - collection_names = item.get("collection_names", []) + query_result = { + "documents": [documents], + "metadatas": [metadatas], + } else: - collection_names.append(item["id"]) + # Fallback to collection names + if item.get("legacy"): + collection_names = item.get("collection_names", []) + else: + collection_names.append(item["id"]) elif item.get("docs"): # BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL diff --git a/backend/open_webui/retrieval/web/firecrawl.py b/backend/open_webui/retrieval/web/firecrawl.py index a85fc51fbd..2d9b104bca 100644 --- a/backend/open_webui/retrieval/web/firecrawl.py +++ b/backend/open_webui/retrieval/web/firecrawl.py @@ -1,11 +1,10 @@ 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"]) @@ -18,27 +17,20 @@ def search_firecrawl( 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, - }, + from firecrawl import FirecrawlApp + + firecrawl = FirecrawlApp(api_key=firecrawl_api_key, api_url=firecrawl_url) + response = firecrawl.search( + query=query, limit=count, ignore_invalid_urls=True, timeout=count * 3 ) - response.raise_for_status() - results = response.json().get("data", []) + results = response.web if filter_list: results = get_filtered_results(results, filter_list) results = [ SearchResult( - link=result.get("url"), - title=result.get("title"), - snippet=result.get("description"), + link=result.url, + title=result.title, + snippet=result.description, ) for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/google_pse.py b/backend/open_webui/retrieval/web/google_pse.py index 2d2b863b42..69de24711a 100644 --- a/backend/open_webui/retrieval/web/google_pse.py +++ b/backend/open_webui/retrieval/web/google_pse.py @@ -15,6 +15,7 @@ def search_google_pse( query: str, count: int, filter_list: Optional[list[str]] = None, + referer: Optional[str] = None, ) -> list[SearchResult]: """Search using Google's Programmable Search Engine API and return the results as a list of SearchResult objects. Handles pagination for counts greater than 10. @@ -30,7 +31,11 @@ def search_google_pse( list[SearchResult]: A list of SearchResult objects. """ url = "https://www.googleapis.com/customsearch/v1" + headers = {"Content-Type": "application/json"} + if referer: + headers["Referer"] = referer + all_results = [] start_index = 1 # Google PSE start parameter is 1-based diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 61356adb56..91699a157b 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -4,7 +4,6 @@ import ssl import urllib.parse import urllib.request -from collections import defaultdict from datetime import datetime, time, timedelta from typing import ( Any, @@ -17,11 +16,12 @@ Union, Literal, ) + +from fastapi.concurrency import run_in_threadpool import aiohttp import certifi import validators from langchain_community.document_loaders import PlaywrightURLLoader, WebBaseLoader -from langchain_community.document_loaders.firecrawl import FireCrawlLoader from langchain_community.document_loaders.base import BaseLoader from langchain_core.documents import Document from open_webui.retrieval.loaders.tavily import TavilyLoader @@ -39,7 +39,8 @@ EXTERNAL_WEB_LOADER_URL, EXTERNAL_WEB_LOADER_API_KEY, ) -from open_webui.env import SRC_LOG_LEVELS, AIOHTTP_CLIENT_SESSION_SSL +from open_webui.env import SRC_LOG_LEVELS + log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) @@ -142,13 +143,13 @@ def _sync_wait_for_rate_limit(self): class URLProcessingMixin: - def _verify_ssl_cert(self, url: str) -> bool: + async def _verify_ssl_cert(self, url: str) -> bool: """Verify SSL certificate for a URL.""" - return verify_ssl_cert(url) + return await run_in_threadpool(verify_ssl_cert, url) async def _safe_process_url(self, url: str) -> bool: """Perform safety checks before processing a URL.""" - if self.verify_ssl and not self._verify_ssl_cert(url): + if self.verify_ssl and not await self._verify_ssl_cert(url): raise ValueError(f"SSL certificate verification failed for {url}") await self._wait_for_rate_limit() return True @@ -189,13 +190,12 @@ def __init__( (uses FIRE_CRAWL_API_KEY environment variable if not provided). api_url: Base URL for FireCrawl API. Defaults to official API endpoint. mode: Operation mode selection: - - 'crawl': Website crawling mode (default) - - 'scrape': Direct page scraping + - 'crawl': Website crawling mode + - 'scrape': Direct page scraping (default) - 'map': Site map generation proxy: Proxy override settings for the FireCrawl API. params: The parameters to pass to the Firecrawl API. - Examples include crawlerOptions. - For more details, visit: https://github.com/mendableai/firecrawl-py + For more details, visit: https://docs.firecrawl.dev/sdks/python#batch-scrape """ proxy_server = proxy.get("server") if proxy else None if trust_env and not proxy_server: @@ -215,50 +215,88 @@ def __init__( self.api_key = api_key self.api_url = api_url self.mode = mode - self.params = params + self.params = params or {} def lazy_load(self) -> Iterator[Document]: - """Load documents concurrently using FireCrawl.""" - for url in self.web_paths: - try: - self._safe_process_url_sync(url) - loader = FireCrawlLoader( - url=url, - api_key=self.api_key, - api_url=self.api_url, - mode=self.mode, - params=self.params, + """Load documents using FireCrawl batch_scrape.""" + log.debug( + "Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s", + len(self.web_paths), + self.mode, + self.params, + ) + try: + from firecrawl import FirecrawlApp + + firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url) + result = firecrawl.batch_scrape( + self.web_paths, + formats=["markdown"], + skip_tls_verification=not self.verify_ssl, + ignore_invalid_urls=True, + remove_base64_images=True, + max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values + wait_timeout=len(self.web_paths) * 3, + **self.params, + ) + + if result.status != "completed": + raise RuntimeError( + f"FireCrawl batch scrape did not complete successfully. result: {result}" ) - 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}") - continue + + for data in result.data: + metadata = data.metadata or {} + yield Document( + page_content=data.markdown or "", + metadata={"source": metadata.url or metadata.source_url or ""}, + ) + + except Exception as e: + if self.continue_on_failure: + log.exception(f"Error extracting content from URLs: {e}") + else: raise e async def alazy_load(self): """Async version of lazy_load.""" - for url in self.web_paths: - try: - await self._safe_process_url(url) - loader = FireCrawlLoader( - url=url, - api_key=self.api_key, - api_url=self.api_url, - mode=self.mode, - params=self.params, + log.debug( + "Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s", + len(self.web_paths), + self.mode, + self.params, + ) + try: + from firecrawl import FirecrawlApp + + firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url) + result = firecrawl.batch_scrape( + self.web_paths, + formats=["markdown"], + skip_tls_verification=not self.verify_ssl, + ignore_invalid_urls=True, + remove_base64_images=True, + max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values + wait_timeout=len(self.web_paths) * 3, + **self.params, + ) + + if result.status != "completed": + raise RuntimeError( + f"FireCrawl batch scrape did not complete successfully. result: {result}" ) - 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: - log.exception(f"Error loading {url}: {e}") - continue + + for data in result.data: + metadata = data.metadata or {} + yield Document( + page_content=data.markdown or "", + metadata={"source": metadata.url or metadata.source_url or ""}, + ) + + except Exception as e: + if self.continue_on_failure: + log.exception(f"Error extracting content from URLs: {e}") + else: raise e diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index cb7a57b5b7..45b4f1e692 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -4,6 +4,7 @@ import os import uuid import html +import base64 from functools import lru_cache from pydub import AudioSegment from pydub.silence import split_on_silence @@ -39,13 +40,14 @@ WHISPER_MODEL_DIR, CACHE_DIR, WHISPER_LANGUAGE, + ELEVENLABS_API_BASE_URL, ) from open_webui.constants import ERROR_MESSAGES from open_webui.env import ( + ENV, AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, - ENV, SRC_LOG_LEVELS, DEVICE_TYPE, ENABLE_FORWARD_USER_INFO_HEADERS, @@ -178,6 +180,9 @@ class STTConfigForm(BaseModel): AZURE_LOCALES: str AZURE_BASE_URL: str AZURE_MAX_SPEAKERS: str + MISTRAL_API_KEY: str + MISTRAL_API_BASE_URL: str + MISTRAL_USE_CHAT_COMPLETIONS: bool class AudioConfigUpdateForm(BaseModel): @@ -214,6 +219,9 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)): "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, + "MISTRAL_API_KEY": request.app.state.config.AUDIO_STT_MISTRAL_API_KEY, + "MISTRAL_API_BASE_URL": request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL, + "MISTRAL_USE_CHAT_COMPLETIONS": request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS, }, } @@ -255,6 +263,13 @@ async def update_audio_config( request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = ( form_data.stt.AZURE_MAX_SPEAKERS ) + request.app.state.config.AUDIO_STT_MISTRAL_API_KEY = form_data.stt.MISTRAL_API_KEY + request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = ( + form_data.stt.MISTRAL_API_BASE_URL + ) + request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = ( + form_data.stt.MISTRAL_USE_CHAT_COMPLETIONS + ) if request.app.state.config.STT_ENGINE == "": request.app.state.faster_whisper_model = set_faster_whisper_model( @@ -290,6 +305,9 @@ async def update_audio_config( "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, + "MISTRAL_API_KEY": request.app.state.config.AUDIO_STT_MISTRAL_API_KEY, + "MISTRAL_API_BASE_URL": request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL, + "MISTRAL_USE_CHAT_COMPLETIONS": request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS, }, } @@ -413,7 +431,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): timeout=timeout, trust_env=True ) as session: async with session.post( - f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}", + f"{ELEVENLABS_API_BASE_URL}/v1/text-to-speech/{voice_id}", json={ "text": payload["input"], "model_id": request.app.state.config.TTS_MODEL, @@ -828,6 +846,186 @@ def transcription_handler(request, file_path, metadata): detail=detail if detail else "Open WebUI: Server Connection Error", ) + elif request.app.state.config.STT_ENGINE == "mistral": + # Check file exists + if not os.path.exists(file_path): + raise HTTPException(status_code=400, detail="Audio file not found") + + # Check file size + file_size = os.path.getsize(file_path) + if file_size > MAX_FILE_SIZE: + raise HTTPException( + status_code=400, + detail=f"File size exceeds limit of {MAX_FILE_SIZE_MB}MB", + ) + + api_key = request.app.state.config.AUDIO_STT_MISTRAL_API_KEY + api_base_url = ( + request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL + or "https://api.mistral.ai/v1" + ) + use_chat_completions = ( + request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS + ) + + if not api_key: + raise HTTPException( + status_code=400, + detail="Mistral API key is required for Mistral STT", + ) + + r = None + try: + # Use voxtral-mini-latest as the default model for transcription + model = request.app.state.config.STT_MODEL or "voxtral-mini-latest" + + log.info( + f"Mistral STT - model: {model}, " + f"method: {'chat_completions' if use_chat_completions else 'transcriptions'}" + ) + + if use_chat_completions: + # Use chat completions API with audio input + # This method requires mp3 or wav format + audio_file_to_use = file_path + + if is_audio_conversion_required(file_path): + log.debug("Converting audio to mp3 for chat completions API") + converted_path = convert_audio_to_mp3(file_path) + if converted_path: + audio_file_to_use = converted_path + else: + log.error("Audio conversion failed") + raise HTTPException( + status_code=500, + detail="Audio conversion failed. Chat completions API requires mp3 or wav format.", + ) + + # Read and encode audio file as base64 + with open(audio_file_to_use, "rb") as audio_file: + audio_base64 = base64.b64encode(audio_file.read()).decode("utf-8") + + # Prepare chat completions request + url = f"{api_base_url}/chat/completions" + + # Add language instruction if specified + language = metadata.get("language", None) if metadata else None + if language: + text_instruction = f"Transcribe this audio exactly as spoken in {language}. Do not translate it." + else: + text_instruction = "Transcribe this audio exactly as spoken in its original language. Do not translate it to another language." + + payload = { + "model": model, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "input_audio", + "input_audio": audio_base64, + }, + {"type": "text", "text": text_instruction}, + ], + } + ], + } + + r = requests.post( + url=url, + json=payload, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + ) + + r.raise_for_status() + response = r.json() + + # Extract transcript from chat completion response + transcript = ( + response.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + .strip() + ) + if not transcript: + raise ValueError("Empty transcript in response") + + data = {"text": transcript} + + else: + # Use dedicated transcriptions API + url = f"{api_base_url}/audio/transcriptions" + + # Determine the MIME type + mime_type, _ = mimetypes.guess_type(file_path) + if not mime_type: + mime_type = "audio/webm" + + # Use context manager to ensure file is properly closed + with open(file_path, "rb") as audio_file: + files = {"file": (filename, audio_file, mime_type)} + data_form = {"model": model} + + # Add language if specified in metadata + language = metadata.get("language", None) if metadata else None + if language: + data_form["language"] = language + + r = requests.post( + url=url, + files=files, + data=data_form, + headers={ + "Authorization": f"Bearer {api_key}", + }, + ) + + r.raise_for_status() + response = r.json() + + # Extract transcript from response + transcript = response.get("text", "").strip() + if not transcript: + raise ValueError("Empty transcript in response") + + data = {"text": transcript} + + # Save transcript to json file (consistent with other providers) + transcript_file = f"{file_dir}/{id}.json" + with open(transcript_file, "w") as f: + json.dump(data, f) + + log.debug(data) + return data + + except ValueError as e: + log.exception("Error parsing Mistral response") + raise HTTPException( + status_code=500, + detail=f"Failed to parse Mistral response: {str(e)}", + ) + except requests.exceptions.RequestException as e: + log.exception(e) + detail = None + + try: + if r is not None and r.status_code != 200: + res = r.json() + if "error" in res: + detail = f"External: {res['error'].get('message', '')}" + else: + detail = f"External: {r.text}" + except Exception: + detail = f"External: {e}" + + raise HTTPException( + status_code=getattr(r, "status_code", 500) if r else 500, + detail=detail if detail else "Open WebUI: Server Connection Error", + ) + def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None): log.info(f"transcribe: {file_path} {metadata}") @@ -1037,7 +1235,7 @@ def get_available_models(request: Request) -> list[dict]: elif request.app.state.config.TTS_ENGINE == "elevenlabs": try: response = requests.get( - "https://api.elevenlabs.io/v1/models", + f"{ELEVENLABS_API_BASE_URL}/v1/models", headers={ "xi-api-key": request.app.state.config.TTS_API_KEY, "Content-Type": "application/json", @@ -1141,7 +1339,7 @@ def get_elevenlabs_voices(api_key: str) -> dict: try: # TODO: Add retries response = requests.get( - "https://api.elevenlabs.io/v1/voices", + f"{ELEVENLABS_API_BASE_URL}/v1/voices", headers={ "xi-api-key": api_key, "Content-Type": "application/json", diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 8e8dd626d0..5846df84b1 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -524,6 +524,15 @@ async def signin(request: Request, response: Response, form_data: SigninForm): user = Auths.authenticate_user(admin_email.lower(), admin_password) else: + password_bytes = form_data.password.encode("utf-8") + if len(password_bytes) > 72: + # TODO: Implement other hashing algorithms that support longer passwords + log.info("Password too long, truncating to 72 bytes for bcrypt") + password_bytes = password_bytes[:72] + + # decode safely — ignore incomplete UTF-8 sequences + form_data.password = password_bytes.decode("utf-8", errors="ignore") + user = Auths.authenticate_user(form_data.email.lower(), form_data.password) if user: diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 28bbea8710..683068da0f 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -1,4 +1,5 @@ import logging +import copy from fastapi import APIRouter, Depends, Request, HTTPException from pydantic import BaseModel, ConfigDict, Field import aiohttp @@ -15,6 +16,7 @@ set_tool_servers, ) from open_webui.utils.mcp.client import MCPClient +from open_webui.models.oauth_sessions import OAuthSessions from open_webui.env import SRC_LOG_LEVELS @@ -165,6 +167,21 @@ async def set_tool_servers_config( form_data: ToolServersConfigForm, user=Depends(get_admin_user), ): + for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS: + server_type = connection.get("type", "openapi") + auth_type = connection.get("auth_type", "none") + + if auth_type == "oauth_2.1": + # Remove existing OAuth clients for tool servers + server_id = connection.get("info", {}).get("id") + client_key = f"{server_type}:{server_id}" + + try: + request.app.state.oauth_client_manager.remove_client(client_key) + except: + pass + + # Set new tool server connections request.app.state.config.TOOL_SERVER_CONNECTIONS = [ connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS ] @@ -176,6 +193,7 @@ async def set_tool_servers_config( if server_type == "mcp": server_id = connection.get("info", {}).get("id") auth_type = connection.get("auth_type", "none") + if auth_type == "oauth_2.1" and server_id: try: oauth_client_info = connection.get("info", {}).get( @@ -211,7 +229,7 @@ async def verify_tool_servers_config( log.debug( f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}" ) - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( discovery_url ) as oauth_server_metadata_response: diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 84d8f841cf..2a5c3e5bb1 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -115,6 +115,10 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external" ): process_file(request, ProcessFileForm(file_id=file_item.id), user=user) + else: + raise Exception( + f"File type {file.content_type} is not supported for processing" + ) else: log.info( f"File type {file.content_type} is not provided, but trying to process anyway" diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 059b3a23d7..b1b3994968 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -1,5 +1,6 @@ import asyncio import base64 +import uuid import io import json import logging @@ -10,23 +11,22 @@ from urllib.parse import quote import requests -from fastapi import ( - APIRouter, - Depends, - HTTPException, - Request, - UploadFile, -) +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS -from open_webui.routers.files import upload_file_handler +from open_webui.routers.files import upload_file_handler, get_file_content_by_id from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.headers import include_user_info_headers from open_webui.utils.images.comfyui import ( - ComfyUIGenerateImageForm, + ComfyUICreateImageForm, + ComfyUIEditImageForm, ComfyUIWorkflow, - comfyui_generate_image, + comfyui_upload_image, + comfyui_create_image, + comfyui_edit_image, ) from pydantic import BaseModel @@ -36,210 +36,9 @@ IMAGE_CACHE_DIR = CACHE_DIR / "image" / "generations" IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) - router = APIRouter() -@router.get("/config") -async def get_config(request: Request, user=Depends(get_admin_user)): - return { - "enabled": request.app.state.config.ENABLE_IMAGE_GENERATION, - "engine": request.app.state.config.IMAGE_GENERATION_ENGINE, - "prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, - "openai": { - "OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL, - "OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION, - "OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY, - }, - "automatic1111": { - "AUTOMATIC1111_BASE_URL": request.app.state.config.AUTOMATIC1111_BASE_URL, - "AUTOMATIC1111_API_AUTH": request.app.state.config.AUTOMATIC1111_API_AUTH, - "AUTOMATIC1111_CFG_SCALE": request.app.state.config.AUTOMATIC1111_CFG_SCALE, - "AUTOMATIC1111_SAMPLER": request.app.state.config.AUTOMATIC1111_SAMPLER, - "AUTOMATIC1111_SCHEDULER": request.app.state.config.AUTOMATIC1111_SCHEDULER, - }, - "comfyui": { - "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL, - "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY, - "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW, - "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES, - }, - "gemini": { - "GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL, - "GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY, - }, - } - - -class OpenAIConfigForm(BaseModel): - OPENAI_API_BASE_URL: str - OPENAI_API_VERSION: str - OPENAI_API_KEY: str - - -class Automatic1111ConfigForm(BaseModel): - AUTOMATIC1111_BASE_URL: str - AUTOMATIC1111_API_AUTH: str - AUTOMATIC1111_CFG_SCALE: Optional[str | float | int] - AUTOMATIC1111_SAMPLER: Optional[str] - AUTOMATIC1111_SCHEDULER: Optional[str] - - -class ComfyUIConfigForm(BaseModel): - COMFYUI_BASE_URL: str - COMFYUI_API_KEY: str - COMFYUI_WORKFLOW: str - COMFYUI_WORKFLOW_NODES: list[dict] - - -class GeminiConfigForm(BaseModel): - GEMINI_API_BASE_URL: str - GEMINI_API_KEY: str - - -class ConfigForm(BaseModel): - enabled: bool - engine: str - prompt_generation: bool - openai: OpenAIConfigForm - automatic1111: Automatic1111ConfigForm - comfyui: ComfyUIConfigForm - gemini: GeminiConfigForm - - -@router.post("/config/update") -async def update_config( - request: Request, form_data: ConfigForm, user=Depends(get_admin_user) -): - request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.engine - request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.enabled - - request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ( - form_data.prompt_generation - ) - - request.app.state.config.IMAGES_OPENAI_API_BASE_URL = ( - form_data.openai.OPENAI_API_BASE_URL - ) - request.app.state.config.IMAGES_OPENAI_API_VERSION = ( - form_data.openai.OPENAI_API_VERSION - ) - request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.openai.OPENAI_API_KEY - - request.app.state.config.IMAGES_GEMINI_API_BASE_URL = ( - form_data.gemini.GEMINI_API_BASE_URL - ) - request.app.state.config.IMAGES_GEMINI_API_KEY = form_data.gemini.GEMINI_API_KEY - - request.app.state.config.AUTOMATIC1111_BASE_URL = ( - form_data.automatic1111.AUTOMATIC1111_BASE_URL - ) - request.app.state.config.AUTOMATIC1111_API_AUTH = ( - form_data.automatic1111.AUTOMATIC1111_API_AUTH - ) - - request.app.state.config.AUTOMATIC1111_CFG_SCALE = ( - float(form_data.automatic1111.AUTOMATIC1111_CFG_SCALE) - if form_data.automatic1111.AUTOMATIC1111_CFG_SCALE - else None - ) - request.app.state.config.AUTOMATIC1111_SAMPLER = ( - form_data.automatic1111.AUTOMATIC1111_SAMPLER - if form_data.automatic1111.AUTOMATIC1111_SAMPLER - else None - ) - request.app.state.config.AUTOMATIC1111_SCHEDULER = ( - form_data.automatic1111.AUTOMATIC1111_SCHEDULER - if form_data.automatic1111.AUTOMATIC1111_SCHEDULER - else None - ) - - request.app.state.config.COMFYUI_BASE_URL = ( - form_data.comfyui.COMFYUI_BASE_URL.strip("/") - ) - request.app.state.config.COMFYUI_API_KEY = form_data.comfyui.COMFYUI_API_KEY - - request.app.state.config.COMFYUI_WORKFLOW = form_data.comfyui.COMFYUI_WORKFLOW - request.app.state.config.COMFYUI_WORKFLOW_NODES = ( - form_data.comfyui.COMFYUI_WORKFLOW_NODES - ) - - return { - "enabled": request.app.state.config.ENABLE_IMAGE_GENERATION, - "engine": request.app.state.config.IMAGE_GENERATION_ENGINE, - "prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, - "openai": { - "OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL, - "OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION, - "OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY, - }, - "automatic1111": { - "AUTOMATIC1111_BASE_URL": request.app.state.config.AUTOMATIC1111_BASE_URL, - "AUTOMATIC1111_API_AUTH": request.app.state.config.AUTOMATIC1111_API_AUTH, - "AUTOMATIC1111_CFG_SCALE": request.app.state.config.AUTOMATIC1111_CFG_SCALE, - "AUTOMATIC1111_SAMPLER": request.app.state.config.AUTOMATIC1111_SAMPLER, - "AUTOMATIC1111_SCHEDULER": request.app.state.config.AUTOMATIC1111_SCHEDULER, - }, - "comfyui": { - "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL, - "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY, - "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW, - "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES, - }, - "gemini": { - "GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL, - "GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY, - }, - } - - -def get_automatic1111_api_auth(request: Request): - if request.app.state.config.AUTOMATIC1111_API_AUTH is None: - return "" - else: - auth1111_byte_string = request.app.state.config.AUTOMATIC1111_API_AUTH.encode( - "utf-8" - ) - auth1111_base64_encoded_bytes = base64.b64encode(auth1111_byte_string) - auth1111_base64_encoded_string = auth1111_base64_encoded_bytes.decode("utf-8") - return f"Basic {auth1111_base64_encoded_string}" - - -@router.get("/config/url/verify") -async def verify_url(request: Request, user=Depends(get_admin_user)): - if request.app.state.config.IMAGE_GENERATION_ENGINE == "automatic1111": - try: - r = requests.get( - url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options", - headers={"authorization": get_automatic1111_api_auth(request)}, - ) - r.raise_for_status() - return True - except Exception: - request.app.state.config.ENABLE_IMAGE_GENERATION = False - raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) - elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": - - headers = None - if request.app.state.config.COMFYUI_API_KEY: - headers = { - "Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}" - } - - try: - r = requests.get( - url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info", - headers=headers, - ) - r.raise_for_status() - return True - except Exception: - request.app.state.config.ENABLE_IMAGE_GENERATION = False - raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) - else: - return True - - def set_image_model(request: Request, model: str): log.info(f"Setting image model to {model}") request.app.state.config.IMAGE_GENERATION_MODEL = model @@ -295,28 +94,101 @@ def get_image_model(request): raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) -class ImageConfigForm(BaseModel): - MODEL: str - IMAGE_SIZE: str - IMAGE_STEPS: int +class ImagesConfig(BaseModel): + ENABLE_IMAGE_GENERATION: bool + ENABLE_IMAGE_PROMPT_GENERATION: bool + + IMAGE_GENERATION_ENGINE: str + IMAGE_GENERATION_MODEL: str + IMAGE_SIZE: Optional[str] + IMAGE_STEPS: Optional[int] + + IMAGES_OPENAI_API_BASE_URL: str + IMAGES_OPENAI_API_KEY: str + IMAGES_OPENAI_API_VERSION: str + + AUTOMATIC1111_BASE_URL: str + AUTOMATIC1111_API_AUTH: str + AUTOMATIC1111_PARAMS: Optional[dict | str] + + COMFYUI_BASE_URL: str + COMFYUI_API_KEY: str + COMFYUI_WORKFLOW: str + COMFYUI_WORKFLOW_NODES: list[dict] + + IMAGES_GEMINI_API_BASE_URL: str + IMAGES_GEMINI_API_KEY: str + IMAGES_GEMINI_ENDPOINT_METHOD: str + + IMAGE_EDIT_ENGINE: str + IMAGE_EDIT_MODEL: str + IMAGE_EDIT_SIZE: Optional[str] + + IMAGES_EDIT_OPENAI_API_BASE_URL: str + IMAGES_EDIT_OPENAI_API_KEY: str + IMAGES_EDIT_OPENAI_API_VERSION: str + IMAGES_EDIT_GEMINI_API_BASE_URL: str + IMAGES_EDIT_GEMINI_API_KEY: str + IMAGES_EDIT_COMFYUI_BASE_URL: str + IMAGES_EDIT_COMFYUI_API_KEY: str + IMAGES_EDIT_COMFYUI_WORKFLOW: str + IMAGES_EDIT_COMFYUI_WORKFLOW_NODES: list[dict] -@router.get("/image/config") -async def get_image_config(request: Request, user=Depends(get_admin_user)): +@router.get("/config", response_model=ImagesConfig) +async def get_config(request: Request, user=Depends(get_admin_user)): return { - "MODEL": request.app.state.config.IMAGE_GENERATION_MODEL, + "ENABLE_IMAGE_GENERATION": request.app.state.config.ENABLE_IMAGE_GENERATION, + "ENABLE_IMAGE_PROMPT_GENERATION": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, + "IMAGE_GENERATION_ENGINE": request.app.state.config.IMAGE_GENERATION_ENGINE, + "IMAGE_GENERATION_MODEL": request.app.state.config.IMAGE_GENERATION_MODEL, "IMAGE_SIZE": request.app.state.config.IMAGE_SIZE, "IMAGE_STEPS": request.app.state.config.IMAGE_STEPS, + "IMAGES_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL, + "IMAGES_OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY, + "IMAGES_OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION, + "AUTOMATIC1111_BASE_URL": request.app.state.config.AUTOMATIC1111_BASE_URL, + "AUTOMATIC1111_API_AUTH": request.app.state.config.AUTOMATIC1111_API_AUTH, + "AUTOMATIC1111_PARAMS": request.app.state.config.AUTOMATIC1111_PARAMS, + "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL, + "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY, + "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW, + "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES, + "IMAGES_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL, + "IMAGES_GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY, + "IMAGES_GEMINI_ENDPOINT_METHOD": request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD, + "IMAGE_EDIT_ENGINE": request.app.state.config.IMAGE_EDIT_ENGINE, + "IMAGE_EDIT_MODEL": request.app.state.config.IMAGE_EDIT_MODEL, + "IMAGE_EDIT_SIZE": request.app.state.config.IMAGE_EDIT_SIZE, + "IMAGES_EDIT_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL, + "IMAGES_EDIT_OPENAI_API_KEY": request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY, + "IMAGES_EDIT_OPENAI_API_VERSION": request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION, + "IMAGES_EDIT_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL, + "IMAGES_EDIT_GEMINI_API_KEY": request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, + "IMAGES_EDIT_COMFYUI_BASE_URL": request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, + "IMAGES_EDIT_COMFYUI_API_KEY": request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, + "IMAGES_EDIT_COMFYUI_WORKFLOW": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, + "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, } -@router.post("/image/config/update") -async def update_image_config( - request: Request, form_data: ImageConfigForm, user=Depends(get_admin_user) +@router.post("/config/update") +async def update_config( + request: Request, form_data: ImagesConfig, user=Depends(get_admin_user) ): - set_image_model(request, form_data.MODEL) + request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.ENABLE_IMAGE_GENERATION + + # Create Image + request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ( + form_data.ENABLE_IMAGE_PROMPT_GENERATION + ) - if form_data.IMAGE_SIZE == "auto" and form_data.MODEL != "gpt-image-1": + request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE + set_image_model(request, form_data.IMAGE_GENERATION_MODEL) + if ( + form_data.IMAGE_SIZE == "auto" + and form_data.IMAGE_GENERATION_MODEL != "gpt-image-1" + ): raise HTTPException( status_code=400, detail=ERROR_MESSAGES.INCORRECT_FORMAT( @@ -325,7 +197,11 @@ async def update_image_config( ) pattern = r"^\d+x\d+$" - if form_data.IMAGE_SIZE == "auto" or re.match(pattern, form_data.IMAGE_SIZE): + if ( + form_data.IMAGE_SIZE == "auto" + or form_data.IMAGE_SIZE == "" + or re.match(pattern, form_data.IMAGE_SIZE) + ): request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE else: raise HTTPException( @@ -341,13 +217,146 @@ async def update_image_config( detail=ERROR_MESSAGES.INCORRECT_FORMAT(" (e.g., 50)."), ) + request.app.state.config.IMAGES_OPENAI_API_BASE_URL = ( + form_data.IMAGES_OPENAI_API_BASE_URL + ) + request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.IMAGES_OPENAI_API_KEY + request.app.state.config.IMAGES_OPENAI_API_VERSION = ( + form_data.IMAGES_OPENAI_API_VERSION + ) + + request.app.state.config.AUTOMATIC1111_BASE_URL = form_data.AUTOMATIC1111_BASE_URL + request.app.state.config.AUTOMATIC1111_API_AUTH = form_data.AUTOMATIC1111_API_AUTH + request.app.state.config.AUTOMATIC1111_PARAMS = form_data.AUTOMATIC1111_PARAMS + + request.app.state.config.COMFYUI_BASE_URL = form_data.COMFYUI_BASE_URL.strip("/") + request.app.state.config.COMFYUI_API_KEY = form_data.COMFYUI_API_KEY + request.app.state.config.COMFYUI_WORKFLOW = form_data.COMFYUI_WORKFLOW + request.app.state.config.COMFYUI_WORKFLOW_NODES = form_data.COMFYUI_WORKFLOW_NODES + + request.app.state.config.IMAGES_GEMINI_API_BASE_URL = ( + form_data.IMAGES_GEMINI_API_BASE_URL + ) + request.app.state.config.IMAGES_GEMINI_API_KEY = form_data.IMAGES_GEMINI_API_KEY + request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD = ( + form_data.IMAGES_GEMINI_ENDPOINT_METHOD + ) + + # Edit Image + request.app.state.config.IMAGE_EDIT_ENGINE = form_data.IMAGE_EDIT_ENGINE + request.app.state.config.IMAGE_EDIT_MODEL = form_data.IMAGE_EDIT_MODEL + request.app.state.config.IMAGE_EDIT_SIZE = form_data.IMAGE_EDIT_SIZE + + request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL = ( + form_data.IMAGES_OPENAI_API_BASE_URL + ) + request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY = ( + form_data.IMAGES_OPENAI_API_KEY + ) + request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION = ( + form_data.IMAGES_EDIT_OPENAI_API_VERSION + ) + + request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL = ( + form_data.IMAGES_EDIT_GEMINI_API_BASE_URL + ) + request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY = ( + form_data.IMAGES_EDIT_GEMINI_API_KEY + ) + + request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL = ( + form_data.IMAGES_EDIT_COMFYUI_BASE_URL.strip("/") + ) + request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY = ( + form_data.IMAGES_EDIT_COMFYUI_API_KEY + ) + request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW = ( + form_data.IMAGES_EDIT_COMFYUI_WORKFLOW + ) + request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = ( + form_data.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES + ) + return { - "MODEL": request.app.state.config.IMAGE_GENERATION_MODEL, + "ENABLE_IMAGE_GENERATION": request.app.state.config.ENABLE_IMAGE_GENERATION, + "ENABLE_IMAGE_PROMPT_GENERATION": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, + "IMAGE_GENERATION_ENGINE": request.app.state.config.IMAGE_GENERATION_ENGINE, + "IMAGE_GENERATION_MODEL": request.app.state.config.IMAGE_GENERATION_MODEL, "IMAGE_SIZE": request.app.state.config.IMAGE_SIZE, "IMAGE_STEPS": request.app.state.config.IMAGE_STEPS, + "IMAGES_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL, + "IMAGES_OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY, + "IMAGES_OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION, + "AUTOMATIC1111_BASE_URL": request.app.state.config.AUTOMATIC1111_BASE_URL, + "AUTOMATIC1111_API_AUTH": request.app.state.config.AUTOMATIC1111_API_AUTH, + "AUTOMATIC1111_PARAMS": request.app.state.config.AUTOMATIC1111_PARAMS, + "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL, + "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY, + "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW, + "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES, + "IMAGES_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL, + "IMAGES_GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY, + "IMAGES_GEMINI_ENDPOINT_METHOD": request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD, + "IMAGE_EDIT_ENGINE": request.app.state.config.IMAGE_EDIT_ENGINE, + "IMAGE_EDIT_MODEL": request.app.state.config.IMAGE_EDIT_MODEL, + "IMAGE_EDIT_SIZE": request.app.state.config.IMAGE_EDIT_SIZE, + "IMAGES_EDIT_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL, + "IMAGES_EDIT_OPENAI_API_KEY": request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY, + "IMAGES_EDIT_OPENAI_API_VERSION": request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION, + "IMAGES_EDIT_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL, + "IMAGES_EDIT_GEMINI_API_KEY": request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, + "IMAGES_EDIT_COMFYUI_BASE_URL": request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, + "IMAGES_EDIT_COMFYUI_API_KEY": request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, + "IMAGES_EDIT_COMFYUI_WORKFLOW": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, + "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, } +def get_automatic1111_api_auth(request: Request): + if request.app.state.config.AUTOMATIC1111_API_AUTH is None: + return "" + else: + auth1111_byte_string = request.app.state.config.AUTOMATIC1111_API_AUTH.encode( + "utf-8" + ) + auth1111_base64_encoded_bytes = base64.b64encode(auth1111_byte_string) + auth1111_base64_encoded_string = auth1111_base64_encoded_bytes.decode("utf-8") + return f"Basic {auth1111_base64_encoded_string}" + + +@router.get("/config/url/verify") +async def verify_url(request: Request, user=Depends(get_admin_user)): + if request.app.state.config.IMAGE_GENERATION_ENGINE == "automatic1111": + try: + r = requests.get( + url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options", + headers={"authorization": get_automatic1111_api_auth(request)}, + ) + r.raise_for_status() + return True + except Exception: + request.app.state.config.ENABLE_IMAGE_GENERATION = False + raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) + elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": + headers = None + if request.app.state.config.COMFYUI_API_KEY: + headers = { + "Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}" + } + try: + r = requests.get( + url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info", + headers=headers, + ) + r.raise_for_status() + return True + except Exception: + request.app.state.config.ENABLE_IMAGE_GENERATION = False + raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) + else: + return True + + @router.get("/models") def get_models(request: Request, user=Depends(get_verified_user)): try: @@ -430,7 +439,7 @@ def get_models(request: Request, user=Depends(get_verified_user)): raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) -class GenerateImageForm(BaseModel): +class CreateImageForm(BaseModel): model: Optional[str] = None prompt: str size: Optional[str] = None @@ -438,39 +447,36 @@ class GenerateImageForm(BaseModel): negative_prompt: Optional[str] = None -def load_b64_image_data(b64_str): - try: - if "," in b64_str: - header, encoded = b64_str.split(",", 1) - mime_type = header.split(";")[0].lstrip("data:") - img_data = base64.b64decode(encoded) - else: - mime_type = "image/png" - img_data = base64.b64decode(b64_str) - return img_data, mime_type - except Exception as e: - log.exception(f"Error loading image data: {e}") - return None, None +GenerateImageForm = CreateImageForm # Alias for backward compatibility -def load_url_image_data(url, headers=None): +def get_image_data(data: str, headers=None): try: - if headers: - r = requests.get(url, headers=headers) - else: - r = requests.get(url) + if data.startswith("http://") or data.startswith("https://"): + if headers: + r = requests.get(data, headers=headers) + else: + r = requests.get(data) - r.raise_for_status() - if r.headers["content-type"].split("/")[0] == "image": - mime_type = r.headers["content-type"] - return r.content, mime_type + r.raise_for_status() + if r.headers["content-type"].split("/")[0] == "image": + mime_type = r.headers["content-type"] + return r.content, mime_type + else: + log.error("Url does not point to an image.") + return None else: - log.error("Url does not point to an image.") - return None - + if "," in data: + header, encoded = data.split(",", 1) + mime_type = header.split(";")[0].lstrip("data:") + img_data = base64.b64decode(encoded) + else: + mime_type = "image/png" + img_data = base64.b64decode(data) + return img_data, mime_type except Exception as e: - log.exception(f"Error saving image: {e}") - return None + log.exception(f"Error loading image data: {e}") + return None, None def upload_image(request, image_data, content_type, metadata, user): @@ -496,7 +502,7 @@ def upload_image(request, image_data, content_type, metadata, user): @router.post("/generations") async def image_generations( request: Request, - form_data: GenerateImageForm, + form_data: CreateImageForm, user=Depends(get_verified_user), ): # if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default @@ -519,17 +525,14 @@ async def image_generations( r = None try: if request.app.state.config.IMAGE_GENERATION_ENGINE == "openai": - headers = {} - headers["Authorization"] = ( - f"Bearer {request.app.state.config.IMAGES_OPENAI_API_KEY}" - ) - headers["Content-Type"] = "application/json" + + headers = { + "Authorization": f"Bearer {request.app.state.config.IMAGES_OPENAI_API_KEY}", + "Content-Type": "application/json", + } if ENABLE_FORWARD_USER_INFO_HEADERS: - headers["X-OpenWebUI-User-Name"] = quote(user.name, safe=" ") - headers["X-OpenWebUI-User-Id"] = user.id - headers["X-OpenWebUI-User-Email"] = user.email - headers["X-OpenWebUI-User-Role"] = user.role + headers = include_user_info_headers(headers, user) data = { "model": model, @@ -568,31 +571,46 @@ async def image_generations( for image in res["data"]: if image_url := image.get("url", None): - image_data, content_type = load_url_image_data(image_url, headers) + image_data, content_type = get_image_data(image_url, headers) else: - image_data, content_type = load_b64_image_data(image["b64_json"]) + image_data, content_type = get_image_data(image["b64_json"]) url = upload_image(request, image_data, content_type, data, user) images.append({"url": url}) return images elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini": - headers = {} - headers["Content-Type"] = "application/json" - headers["x-goog-api-key"] = request.app.state.config.IMAGES_GEMINI_API_KEY - - data = { - "instances": {"prompt": form_data.prompt}, - "parameters": { - "sampleCount": form_data.n, - "outputOptions": {"mimeType": "image/png"}, - }, + headers = { + "Content-Type": "application/json", + "x-goog-api-key": request.app.state.config.IMAGES_GEMINI_API_KEY, } + data = {} + + if ( + request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == "" + or request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == "predict" + ): + model = f"{model}:predict" + data = { + "instances": {"prompt": form_data.prompt}, + "parameters": { + "sampleCount": form_data.n, + "outputOptions": {"mimeType": "image/png"}, + }, + } + + elif ( + request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD + == "generateContent" + ): + model = f"{model}:generateContent" + data = {"contents": [{"parts": [{"text": form_data.prompt}]}]} + # Use asyncio.to_thread for the requests.post call r = await asyncio.to_thread( requests.post, - url=f"{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}:predict", + url=f"{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}", json=data, headers=headers, ) @@ -601,12 +619,25 @@ async def image_generations( res = r.json() images = [] - for image in res["predictions"]: - image_data, content_type = load_b64_image_data( - image["bytesBase64Encoded"] - ) - url = upload_image(request, image_data, content_type, data, user) - images.append({"url": url}) + + if model.endswith(":predict"): + for image in res["predictions"]: + image_data, content_type = get_image_data( + image["bytesBase64Encoded"] + ) + url = upload_image(request, image_data, content_type, data, user) + images.append({"url": url}) + elif model.endswith(":generateContent"): + for image in res["candidates"]: + for part in image["content"]["parts"]: + if part.get("inlineData", {}).get("data"): + image_data, content_type = get_image_data( + part["inlineData"]["data"] + ) + url = upload_image( + request, image_data, content_type, data, user + ) + images.append({"url": url}) return images @@ -624,7 +655,7 @@ async def image_generations( if form_data.negative_prompt is not None: data["negative_prompt"] = form_data.negative_prompt - form_data = ComfyUIGenerateImageForm( + form_data = ComfyUICreateImageForm( **{ "workflow": ComfyUIWorkflow( **{ @@ -635,7 +666,7 @@ async def image_generations( **data, } ) - res = await comfyui_generate_image( + res = await comfyui_create_image( model, form_data, user.id, @@ -653,7 +684,7 @@ async def image_generations( "Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}" } - image_data, content_type = load_url_image_data(image["url"], headers) + image_data, content_type = get_image_data(image["url"], headers) url = upload_image( request, image_data, @@ -683,14 +714,8 @@ async def image_generations( if form_data.negative_prompt is not None: data["negative_prompt"] = form_data.negative_prompt - if request.app.state.config.AUTOMATIC1111_CFG_SCALE: - data["cfg_scale"] = request.app.state.config.AUTOMATIC1111_CFG_SCALE - - if request.app.state.config.AUTOMATIC1111_SAMPLER: - data["sampler_name"] = request.app.state.config.AUTOMATIC1111_SAMPLER - - if request.app.state.config.AUTOMATIC1111_SCHEDULER: - data["scheduler"] = request.app.state.config.AUTOMATIC1111_SCHEDULER + if request.app.state.config.AUTOMATIC1111_PARAMS: + data = {**data, **request.app.state.config.AUTOMATIC1111_PARAMS} # Use asyncio.to_thread for the requests.post call r = await asyncio.to_thread( @@ -706,7 +731,7 @@ async def image_generations( images = [] for image in res["images"]: - image_data, content_type = load_b64_image_data(image) + image_data, content_type = get_image_data(image) url = upload_image( request, image_data, @@ -723,3 +748,292 @@ async def image_generations( if "error" in data: error = data["error"]["message"] raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error)) + + +class EditImageForm(BaseModel): + image: str | list[str] # base64-encoded image(s) or URL(s) + prompt: str + model: Optional[str] = None + size: Optional[str] = None + n: Optional[int] = None + negative_prompt: Optional[str] = None + + +@router.post("/edit") +async def image_edits( + request: Request, + form_data: EditImageForm, + user=Depends(get_verified_user), +): + size = None + width, height = None, None + if ( + request.app.state.config.IMAGE_EDIT_SIZE + and "x" in request.app.state.config.IMAGE_EDIT_SIZE + ) or (form_data.size and "x" in form_data.size): + size = ( + form_data.size + if form_data.size + else request.app.state.config.IMAGE_EDIT_SIZE + ) + width, height = tuple(map(int, size.split("x"))) + + model = ( + request.app.state.config.IMAGE_EDIT_MODEL + if form_data.model is None + else form_data.model + ) + + try: + + async def load_url_image(data): + if data.startswith("http://") or data.startswith("https://"): + r = await asyncio.to_thread(requests.get, data) + r.raise_for_status() + + image_data = base64.b64encode(r.content).decode("utf-8") + return f"data:{r.headers['content-type']};base64,{image_data}" + + elif data.startswith("/api/v1/files"): + file_id = data.split("/api/v1/files/")[1].split("/content")[0] + file_response = await get_file_content_by_id(file_id, user) + + if isinstance(file_response, FileResponse): + file_path = file_response.path + + with open(file_path, "rb") as f: + file_bytes = f.read() + image_data = base64.b64encode(file_bytes).decode("utf-8") + mime_type, _ = mimetypes.guess_type(file_path) + + return f"data:{mime_type};base64,{image_data}" + + return data + + # Load image(s) from URL(s) if necessary + if isinstance(form_data.image, str): + form_data.image = await load_url_image(form_data.image) + elif isinstance(form_data.image, list): + form_data.image = [await load_url_image(img) for img in form_data.image] + except Exception as e: + raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) + + def get_image_file_item(base64_string): + data = base64_string + header, encoded = data.split(",", 1) + mime_type = header.split(";")[0].lstrip("data:") + image_data = base64.b64decode(encoded) + return ( + "image", + ( + f"{uuid.uuid4()}.png", + io.BytesIO(image_data), + mime_type if mime_type else "image/png", + ), + ) + + r = None + try: + if request.app.state.config.IMAGE_EDIT_ENGINE == "openai": + headers = { + "Authorization": f"Bearer {request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY}", + } + + if ENABLE_FORWARD_USER_INFO_HEADERS: + headers = include_user_info_headers(headers, user) + + data = { + "model": model, + "prompt": form_data.prompt, + **({"n": form_data.n} if form_data.n else {}), + **({"size": size} if size else {}), + **( + {} + if "gpt-image-1" in request.app.state.config.IMAGE_EDIT_MODEL + else {"response_format": "b64_json"} + ), + } + + files = [] + if isinstance(form_data.image, str): + files = [get_image_file_item(form_data.image)] + elif isinstance(form_data.image, list): + for img in form_data.image: + files.append(get_image_file_item(img)) + + url_search_params = "" + if request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION: + url_search_params += f"?api-version={request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION}" + + # Use asyncio.to_thread for the requests.post call + r = await asyncio.to_thread( + requests.post, + url=f"{request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}", + headers=headers, + files=files, + data=data, + ) + + r.raise_for_status() + res = r.json() + + images = [] + for image in res["data"]: + if image_url := image.get("url", None): + image_data, content_type = get_image_data(image_url, headers) + else: + image_data, content_type = get_image_data(image["b64_json"]) + + url = upload_image(request, image_data, content_type, data, user) + images.append({"url": url}) + return images + + elif request.app.state.config.IMAGE_EDIT_ENGINE == "gemini": + headers = { + "Content-Type": "application/json", + "x-goog-api-key": request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, + } + + model = f"{model}:generateContent" + data = {"contents": [{"parts": [{"text": form_data.prompt}]}]} + + if isinstance(form_data.image, str): + data["contents"][0]["parts"].append( + { + "inline_data": { + "mime_type": "image/png", + "data": form_data.image.split(",", 1)[1], + } + } + ) + elif isinstance(form_data.image, list): + data["contents"][0]["parts"].extend( + [ + { + "inline_data": { + "mime_type": "image/png", + "data": image.split(",", 1)[1], + } + } + for image in form_data.image + ] + ) + + # Use asyncio.to_thread for the requests.post call + r = await asyncio.to_thread( + requests.post, + url=f"{request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}", + json=data, + headers=headers, + ) + + r.raise_for_status() + res = r.json() + + images = [] + for image in res["candidates"]: + for part in image["content"]["parts"]: + if part.get("inlineData", {}).get("data"): + image_data, content_type = get_image_data( + part["inlineData"]["data"] + ) + url = upload_image( + request, image_data, content_type, data, user + ) + images.append({"url": url}) + + return images + + elif request.app.state.config.IMAGE_EDIT_ENGINE == "comfyui": + try: + files = [] + if isinstance(form_data.image, str): + files = [get_image_file_item(form_data.image)] + elif isinstance(form_data.image, list): + for img in form_data.image: + files.append(get_image_file_item(img)) + + # Upload images to ComfyUI and get their names + comfyui_images = [] + for file_item in files: + res = await comfyui_upload_image( + file_item, + request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, + request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, + ) + comfyui_images.append(res.get("name", file_item[1][0])) + except Exception as e: + log.debug(f"Error uploading images to ComfyUI: {e}") + raise Exception("Failed to upload images to ComfyUI.") + + data = { + "image": comfyui_images, + "prompt": form_data.prompt, + **({"width": width} if width is not None else {}), + **({"height": height} if height is not None else {}), + **({"n": form_data.n} if form_data.n else {}), + } + + form_data = ComfyUIEditImageForm( + **{ + "workflow": ComfyUIWorkflow( + **{ + "workflow": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, + "nodes": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, + } + ), + **data, + } + ) + res = await comfyui_edit_image( + model, + form_data, + user.id, + request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, + request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, + ) + log.debug(f"res: {res}") + + image_urls = set() + for image in res["data"]: + image_urls.add(image["url"]) + image_urls = list(image_urls) + + # Prioritize output type URLs if available + output_type_urls = [url for url in image_urls if "type=output" in url] + if output_type_urls: + image_urls = output_type_urls + + log.debug(f"Image URLs: {image_urls}") + images = [] + + for image_url in image_urls: + headers = None + if request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY: + headers = { + "Authorization": f"Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}" + } + + image_data, content_type = get_image_data(image_url, headers) + url = upload_image( + request, + image_data, + content_type, + form_data.model_dump(exclude_none=True), + user, + ) + images.append({"url": url}) + + return images + except Exception as e: + error = e + if r != None: + data = r.text + try: + data = json.loads(data) + if "error" in data: + error = data["error"]["message"] + except Exception: + error = data + + raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error)) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 215cd8426c..d69cd4ee42 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -44,7 +44,9 @@ def validate_model_id(model_id: str) -> bool: ########################### -@router.get("/", response_model=list[ModelUserResponse]) +@router.get( + "/list", response_model=list[ModelUserResponse] +) # do NOT use "/" as path, conflicts with main.py async def get_models(id: Optional[str] = None, user=Depends(get_verified_user)): if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: return Models.get_models() diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index ed6249e526..c5294d2c85 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -502,49 +502,55 @@ def extract_data(response): return response return None - def merge_models_lists(model_lists): - log.debug(f"merge_models_lists {model_lists}") - merged_list = [] + def is_supported_openai_models(model_id): + if any( + name in model_id + for name in [ + "babbage", + "dall-e", + "davinci", + "embedding", + "tts", + "whisper", + ] + ): + return False + return True - for idx, models in enumerate(model_lists): - if models is not None and "error" not in models: - merged_list.extend( - [ - { + def get_merged_models(model_lists): + log.debug(f"merge_models_lists {model_lists}") + models = {} + + for idx, model_list in enumerate(model_lists): + if model_list is not None and "error" not in model_list: + for model in model_list: + model_id = model.get("id") or model.get("name") + + if ( + "api.openai.com" + in request.app.state.config.OPENAI_API_BASE_URLS[idx] + and not is_supported_openai_models(model_id) + ): + # Skip unwanted OpenAI models + continue + + if model_id and model_id not in models: + models[model_id] = { **model, - "name": model.get("name", model["id"]), + "name": model.get("name", model_id), "owned_by": "openai", "openai": model, "connection_type": model.get("connection_type", "external"), "urlIdx": idx, } - for model in models - if (model.get("id") or model.get("name")) - and ( - "api.openai.com" - not in request.app.state.config.OPENAI_API_BASE_URLS[idx] - or not any( - name in model["id"] - for name in [ - "babbage", - "dall-e", - "davinci", - "embedding", - "tts", - "whisper", - ] - ) - ) - ] - ) - return merged_list + return models - models = {"data": merge_models_lists(map(extract_data, responses))} + models = get_merged_models(map(extract_data, responses)) log.debug(f"models: {models}") - request.app.state.OPENAI_MODELS = {model["id"]: model for model in models["data"]} - return models + request.app.state.OPENAI_MODELS = models + return {"data": list(models.values())} @router.get("/models") diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index cb66e8926e..f8147372fd 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -465,6 +465,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "DOCLING_PICTURE_DESCRIPTION_API": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API, "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + "MISTRAL_OCR_API_BASE_URL": request.app.state.config.MISTRAL_OCR_API_BASE_URL, "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY, # MinerU settings "MINERU_API_MODE": request.app.state.config.MINERU_API_MODE, @@ -650,6 +651,7 @@ class ConfigForm(BaseModel): DOCLING_PICTURE_DESCRIPTION_API: Optional[dict] = None DOCUMENT_INTELLIGENCE_ENDPOINT: Optional[str] = None DOCUMENT_INTELLIGENCE_KEY: Optional[str] = None + MISTRAL_OCR_API_BASE_URL: Optional[str] = None MISTRAL_OCR_API_KEY: Optional[str] = None # MinerU settings @@ -891,6 +893,12 @@ async def update_rag_config( if form_data.DOCUMENT_INTELLIGENCE_KEY is not None else request.app.state.config.DOCUMENT_INTELLIGENCE_KEY ) + + request.app.state.config.MISTRAL_OCR_API_BASE_URL = ( + form_data.MISTRAL_OCR_API_BASE_URL + if form_data.MISTRAL_OCR_API_BASE_URL is not None + else request.app.state.config.MISTRAL_OCR_API_BASE_URL + ) request.app.state.config.MISTRAL_OCR_API_KEY = ( form_data.MISTRAL_OCR_API_KEY if form_data.MISTRAL_OCR_API_KEY is not None @@ -1182,6 +1190,7 @@ async def update_rag_config( "DOCLING_PICTURE_DESCRIPTION_API": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API, "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + "MISTRAL_OCR_API_BASE_URL": request.app.state.config.MISTRAL_OCR_API_BASE_URL, "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY, # MinerU settings "MINERU_API_MODE": request.app.state.config.MINERU_API_MODE, @@ -1565,6 +1574,7 @@ def process_file( file_path = Storage.get_file(file_path) loader = Loader( engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE, + user=user, DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY, DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL, DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG, @@ -1597,6 +1607,7 @@ def process_file( 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, + MISTRAL_OCR_API_BASE_URL=request.app.state.config.MISTRAL_OCR_API_BASE_URL, MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY, MINERU_API_MODE=request.app.state.config.MINERU_API_MODE, MINERU_API_URL=request.app.state.config.MINERU_API_URL, @@ -1875,6 +1886,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: query, request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + referer=request.app.state.config.WEBUI_URL, ) else: raise Exception( diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 7a8a60d565..0ba37d9f65 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -388,7 +388,7 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): ) -@router.get("/{user_id}/oauth/sessions", response_model=Optional[dict]) +@router.get("/{user_id}/oauth/sessions") async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user)): sessions = OAuthSessions.get_sessions_by_user_id(user_id) if sessions and len(sessions) > 0: diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 47b2c57961..818a57807f 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -18,7 +18,12 @@ get_sentinel_url_from_env, ) +from open_webui.config import ( + CORS_ALLOW_ORIGIN, +) + from open_webui.env import ( + VERSION, ENABLE_WEBSOCKET_SUPPORT, WEBSOCKET_MANAGER, WEBSOCKET_REDIS_URL, @@ -48,6 +53,9 @@ REDIS = None +# Configure CORS for Socket.IO +SOCKETIO_CORS_ORIGINS = "*" if CORS_ALLOW_ORIGIN == ["*"] else CORS_ALLOW_ORIGIN + if WEBSOCKET_MANAGER == "redis": if WEBSOCKET_SENTINEL_HOSTS: mgr = socketio.AsyncRedisManager( @@ -58,7 +66,7 @@ else: mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL) sio = socketio.AsyncServer( - cors_allowed_origins=[], + cors_allowed_origins=SOCKETIO_CORS_ORIGINS, async_mode="asgi", transports=(["websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]), allow_upgrades=ENABLE_WEBSOCKET_SUPPORT, @@ -67,7 +75,7 @@ ) else: sio = socketio.AsyncServer( - cors_allowed_origins=[], + cors_allowed_origins=SOCKETIO_CORS_ORIGINS, async_mode="asgi", transports=(["websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]), allow_upgrades=ENABLE_WEBSOCKET_SUPPORT, diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index b410cbab50..29573cab19 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -1,5 +1,5 @@ from open_webui.routers.images import ( - load_b64_image_data, + get_image_data, upload_image, ) @@ -22,7 +22,7 @@ def get_image_url_from_base64(request, base64_image_string, metadata, user): if "data:image/png;base64" in base64_image_string: image_url = "" # Extract base64 image data from the line - image_data, content_type = load_b64_image_data(base64_image_string) + image_data, content_type = get_image_data(base64_image_string) if image_data is not None: image_url = upload_image( request, diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py new file mode 100644 index 0000000000..3caee50334 --- /dev/null +++ b/backend/open_webui/utils/headers.py @@ -0,0 +1,11 @@ +from urllib.parse import quote + + +def include_user_info_headers(headers, user): + return { + **headers, + "X-OpenWebUI-User-Name": quote(user.name, safe=" "), + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + } diff --git a/backend/open_webui/utils/images/comfyui.py b/backend/open_webui/utils/images/comfyui.py index b86c257591..506723bc92 100644 --- a/backend/open_webui/utils/images/comfyui.py +++ b/backend/open_webui/utils/images/comfyui.py @@ -2,6 +2,8 @@ import json import logging import random +import requests +import aiohttp import urllib.parse import urllib.request from typing import Optional @@ -91,6 +93,25 @@ def get_images(ws, prompt, client_id, base_url, api_key): return {"data": output_images} +async def comfyui_upload_image(image_file_item, base_url, api_key): + url = f"{base_url}/api/upload/image" + headers = {} + + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + _, (filename, file_bytes, mime_type) = image_file_item + + form = aiohttp.FormData() + form.add_field("image", file_bytes, filename=filename, content_type=mime_type) + form.add_field("type", "input") # required by ComfyUI + + async with aiohttp.ClientSession() as session: + async with session.post(url, data=form, headers=headers) as resp: + resp.raise_for_status() + return await resp.json() + + class ComfyUINodeInput(BaseModel): type: Optional[str] = None node_ids: list[str] = [] @@ -103,7 +124,7 @@ class ComfyUIWorkflow(BaseModel): nodes: list[ComfyUINodeInput] -class ComfyUIGenerateImageForm(BaseModel): +class ComfyUICreateImageForm(BaseModel): workflow: ComfyUIWorkflow prompt: str @@ -116,8 +137,98 @@ class ComfyUIGenerateImageForm(BaseModel): seed: Optional[int] = None -async def comfyui_generate_image( - model: str, payload: ComfyUIGenerateImageForm, client_id, base_url, api_key +async def comfyui_create_image( + model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key +): + ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") + workflow = json.loads(payload.workflow.workflow) + + for node in payload.workflow.nodes: + if node.type: + if node.type == "model": + for node_id in node.node_ids: + workflow[node_id]["inputs"][node.key] = model + elif node.type == "prompt": + for node_id in node.node_ids: + workflow[node_id]["inputs"][ + node.key if node.key else "text" + ] = payload.prompt + elif node.type == "negative_prompt": + for node_id in node.node_ids: + workflow[node_id]["inputs"][ + node.key if node.key else "text" + ] = payload.negative_prompt + elif node.type == "width": + for node_id in node.node_ids: + workflow[node_id]["inputs"][ + node.key if node.key else "width" + ] = payload.width + elif node.type == "height": + for node_id in node.node_ids: + workflow[node_id]["inputs"][ + node.key if node.key else "height" + ] = payload.height + elif node.type == "n": + for node_id in node.node_ids: + workflow[node_id]["inputs"][ + node.key if node.key else "batch_size" + ] = payload.n + elif node.type == "steps": + for node_id in node.node_ids: + workflow[node_id]["inputs"][ + node.key if node.key else "steps" + ] = payload.steps + elif node.type == "seed": + seed = ( + payload.seed + if payload.seed + else random.randint(0, 1125899906842624) + ) + for node_id in node.node_ids: + workflow[node_id]["inputs"][node.key] = seed + else: + for node_id in node.node_ids: + workflow[node_id]["inputs"][node.key] = node.value + + try: + ws = websocket.WebSocket() + headers = {"Authorization": f"Bearer {api_key}"} + ws.connect(f"{ws_url}/ws?clientId={client_id}", header=headers) + log.info("WebSocket connection established.") + except Exception as e: + log.exception(f"Failed to connect to WebSocket server: {e}") + return None + + try: + log.info("Sending workflow to WebSocket server.") + log.info(f"Workflow: {workflow}") + images = await asyncio.to_thread( + get_images, ws, workflow, client_id, base_url, api_key + ) + except Exception as e: + log.exception(f"Error while receiving images: {e}") + images = None + + ws.close() + + return images + + +class ComfyUIEditImageForm(BaseModel): + workflow: ComfyUIWorkflow + + image: str | list[str] + prompt: str + width: Optional[int] = None + height: Optional[int] = None + n: Optional[int] = None + + steps: Optional[int] = None + seed: Optional[int] = None + + +async def comfyui_edit_image( + model: str, payload: ComfyUIEditImageForm, client_id, base_url, api_key ): ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") workflow = json.loads(payload.workflow.workflow) @@ -127,6 +238,15 @@ async def comfyui_generate_image( if node.type == "model": for node_id in node.node_ids: workflow[node_id]["inputs"][node.key] = model + elif node.type == "image": + if isinstance(payload.image, list): + # check if multiple images are provided + for idx, node_id in enumerate(node.node_ids): + if idx < len(payload.image): + workflow[node_id]["inputs"][node.key] = payload.image[idx] + else: + for node_id in node.node_ids: + workflow[node_id]["inputs"][node.key] = payload.image elif node.type == "prompt": for node_id in node.node_ids: workflow[node_id]["inputs"][ diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py index 01df38886c..6edfca4f6c 100644 --- a/backend/open_webui/utils/mcp/client.py +++ b/backend/open_webui/utils/mcp/client.py @@ -2,6 +2,8 @@ from typing import Optional from contextlib import AsyncExitStack +import anyio + from mcp import ClientSession from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.client.streamable_http import streamablehttp_client @@ -11,26 +13,29 @@ class MCPClient: def __init__(self): self.session: Optional[ClientSession] = None - self.exit_stack = AsyncExitStack() + self.exit_stack = None async def connect(self, url: str, headers: Optional[dict] = None): - try: - self._streams_context = streamablehttp_client(url, headers=headers) - - transport = await self.exit_stack.enter_async_context(self._streams_context) - read_stream, write_stream, _ = transport - - self._session_context = ClientSession( - read_stream, write_stream - ) # pylint: disable=W0201 - - self.session = await self.exit_stack.enter_async_context( - self._session_context - ) - await self.session.initialize() - except Exception as e: - await self.disconnect() - raise e + async with AsyncExitStack() as exit_stack: + try: + self._streams_context = streamablehttp_client(url, headers=headers) + + transport = await exit_stack.enter_async_context(self._streams_context) + read_stream, write_stream, _ = transport + + self._session_context = ClientSession( + read_stream, write_stream + ) # pylint: disable=W0201 + + self.session = await exit_stack.enter_async_context( + self._session_context + ) + with anyio.fail_after(10): + await self.session.initialize() + self.exit_stack = exit_stack.pop_all() + except Exception as e: + await asyncio.shield(self.disconnect()) + raise e async def list_tool_specs(self) -> Optional[dict]: if not self.session: diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index dd42612eee..e5b84a3d79 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -45,10 +45,10 @@ SearchForm, ) from open_webui.routers.images import ( - load_b64_image_data, image_generations, - GenerateImageForm, - upload_image, + CreateImageForm, + image_edits, + EditImageForm, ) from open_webui.routers.pipelines import ( process_pipeline_inlet_filter, @@ -91,7 +91,7 @@ convert_logit_bias_input_to_json, get_content_from_message, ) -from open_webui.utils.tools import get_tools +from open_webui.utils.tools import get_tools, get_updated_tool_function from open_webui.utils.plugin import load_function_module_by_id from open_webui.utils.filter import ( get_sorted_filter_ids, @@ -718,9 +718,31 @@ async def chat_web_search_handler( return form_data +def get_last_images(message_list): + images = [] + for message in reversed(message_list): + images_flag = False + for file in message.get("files", []): + if file.get("type") == "image": + images.append(file.get("url")) + images_flag = True + + if images_flag: + break + + return images + + async def chat_image_generation_handler( request: Request, form_data: dict, extra_params: dict, user ): + metadata = extra_params.get("__metadata__", {}) + chat_id = metadata.get("chat_id", None) + if not chat_id: + return form_data + + chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id) + __event_emitter__ = extra_params["__event_emitter__"] await __event_emitter__( { @@ -729,87 +751,151 @@ async def chat_image_generation_handler( } ) - messages = form_data["messages"] - user_message = get_last_user_message(messages) + messages_map = chat.chat.get("history", {}).get("messages", {}) + message_id = chat.chat.get("history", {}).get("currentId") + message_list = get_message_list(messages_map, message_id) + user_message = get_last_user_message(message_list) prompt = user_message - negative_prompt = "" + input_images = get_last_images(message_list) - if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION: - try: - res = await generate_image_prompt( - request, - { - "model": form_data["model"], - "messages": messages, - }, - user, - ) + system_message_content = "" + if len(input_images) == 0: + # Create image(s) + if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION: + try: + res = await generate_image_prompt( + request, + { + "model": form_data["model"], + "messages": form_data["messages"], + }, + user, + ) - response = res["choices"][0]["message"]["content"] + response = res["choices"][0]["message"]["content"] - try: - bracket_start = response.find("{") - bracket_end = response.rfind("}") + 1 + try: + bracket_start = response.find("{") + bracket_end = response.rfind("}") + 1 - if bracket_start == -1 or bracket_end == -1: - raise Exception("No JSON object found in the response") + if bracket_start == -1 or bracket_end == -1: + raise Exception("No JSON object found in the response") + + response = response[bracket_start:bracket_end] + response = json.loads(response) + prompt = response.get("prompt", []) + except Exception as e: + prompt = user_message - response = response[bracket_start:bracket_end] - response = json.loads(response) - prompt = response.get("prompt", []) except Exception as e: + log.exception(e) prompt = user_message + try: + images = await image_generations( + request=request, + form_data=CreateImageForm(**{"prompt": prompt}), + user=user, + ) + + await __event_emitter__( + { + "type": "status", + "data": {"description": "Image created", "done": True}, + } + ) + + await __event_emitter__( + { + "type": "files", + "data": { + "files": [ + { + "type": "image", + "url": image["url"], + } + for image in images + ] + }, + } + ) + + system_message_content = "The requested image has been created and is now being shown to the user. Let them know that it has been generated." except Exception as e: - log.exception(e) - prompt = user_message + log.debug(e) - system_message_content = "" + error_message = "" + if isinstance(e, HTTPException): + if e.detail and isinstance(e.detail, dict): + error_message = e.detail.get("message", str(e.detail)) + else: + error_message = str(e.detail) - try: - images = await image_generations( - request=request, - form_data=GenerateImageForm(**{"prompt": prompt}), - user=user, - ) + await __event_emitter__( + { + "type": "status", + "data": { + "description": f"An error occurred while generating an image", + "done": True, + }, + } + ) - await __event_emitter__( - { - "type": "status", - "data": {"description": "Image created", "done": True}, - } - ) + system_message_content = f"Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that an error occurred: {error_message}" + else: + # Edit image(s) + try: + images = await image_edits( + request=request, + form_data=EditImageForm(**{"prompt": prompt, "image": input_images}), + user=user, + ) - await __event_emitter__( - { - "type": "files", - "data": { - "files": [ - { - "type": "image", - "url": image["url"], - } - for image in images - ] - }, - } - ) + await __event_emitter__( + { + "type": "status", + "data": {"description": "Image created", "done": True}, + } + ) - system_message_content = "User is shown the generated image, tell the user that the image has been generated" - except Exception as e: - log.exception(e) - await __event_emitter__( - { - "type": "status", - "data": { - "description": f"An error occurred while generating an image", - "done": True, - }, - } - ) + await __event_emitter__( + { + "type": "files", + "data": { + "files": [ + { + "type": "image", + "url": image["url"], + } + for image in images + ] + }, + } + ) + + system_message_content = "The requested image has been created and is now being shown to the user. Let them know that it has been generated." + except Exception as e: + log.debug(e) + + error_message = "" + if isinstance(e, HTTPException): + if e.detail and isinstance(e.detail, dict): + error_message = e.detail.get("message", str(e.detail)) + else: + error_message = str(e.detail) + + await __event_emitter__( + { + "type": "status", + "data": { + "description": f"An error occurred while generating an image", + "done": True, + }, + } + ) - system_message_content = "Unable to generate an image, tell the user that an error occurred" + system_message_content = f"Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that an error occurred: {error_message}" if system_message_content: form_data["messages"] = add_or_update_system_message( @@ -1307,6 +1393,17 @@ async def tool_function(**kwargs): } except Exception as e: log.debug(e) + if event_emitter: + await event_emitter( + { + "type": "chat:message:error", + "data": { + "error": { + "content": f"Failed to connect to MCP server '{server_id}'" + } + }, + } + ) continue tools_dict = await get_tools( @@ -1543,16 +1640,13 @@ async def background_tasks_handler(): if not metadata.get("chat_id", "").startswith( "local:" ): # Only update titles and tags for non-temp chats - if ( - TASKS.TITLE_GENERATION in tasks - and tasks[TASKS.TITLE_GENERATION] - ): + if TASKS.TITLE_GENERATION in tasks: user_message = get_last_user_message(messages) if user_message and len(user_message) > 100: user_message = user_message[:100] + "..." + title = None if tasks[TASKS.TITLE_GENERATION]: - res = await generate_title( request, { @@ -1603,7 +1697,8 @@ async def background_tasks_handler(): "data": title, } ) - elif len(messages) == 2: + + if title == None and len(messages) == 2: title = messages[0].get("content", user_message) Chats.update_chat_title_by_id(metadata["chat_id"], title) @@ -1939,9 +2034,11 @@ def serialize_content_blocks(content_blocks, raw=False): content = f"{content}{tool_calls_display_content}" elif block["type"] == "reasoning": - reasoning_display_content = "\n".join( - (f"> {line}" if not line.startswith(">") else line) - for line in block["content"].splitlines() + reasoning_display_content = html.escape( + "\n".join( + (f"> {line}" if not line.startswith(">") else line) + for line in block["content"].splitlines() + ) ) reasoning_duration = block.get("duration", None) @@ -2349,7 +2446,9 @@ async def flush_pending_delta_data(threshold: int = 0): ) if data: - if "event" in data: + if "event" in data and not getattr( + request.state, "direct", False + ): await event_emitter(data.get("event", {})) if "selected_model_id" in data: @@ -2740,7 +2839,16 @@ async def flush_pending_delta_data(threshold: int = 0): ) else: - tool_function = tool["callable"] + tool_function = get_updated_tool_function( + function=tool["callable"], + extra_params={ + "__messages__": form_data.get( + "messages", [] + ), + "__files__": metadata.get("files", []), + }, + ) + tool_result = await tool_function( **tool_function_params ) diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 587e2a2c7d..6661fadb9e 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -166,13 +166,18 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) action_ids = [] filter_ids = [] - if "info" in model and "meta" in model["info"]: - action_ids.extend( - model["info"]["meta"].get("actionIds", []) - ) - filter_ids.extend( - model["info"]["meta"].get("filterIds", []) - ) + if "info" in model: + if "meta" in model["info"]: + action_ids.extend( + model["info"]["meta"].get("actionIds", []) + ) + filter_ids.extend( + model["info"]["meta"].get("filterIds", []) + ) + + if "params" in model["info"]: + # Remove params to avoid exposing sensitive info + del model["info"]["params"] model["action_ids"] = action_ids model["filter_ids"] = filter_ids @@ -182,22 +187,40 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) elif custom_model.is_active and ( custom_model.id not in [model["id"] for model in models] ): + # Custom model based on a base model owned_by = "openai" pipe = None - action_ids = [] - filter_ids = [] - - for model in models: + for m in models: if ( - custom_model.base_model_id == model["id"] - or custom_model.base_model_id == model["id"].split(":")[0] + custom_model.base_model_id == m["id"] + or custom_model.base_model_id == m["id"].split(":")[0] ): - owned_by = model.get("owned_by", "unknown owner") - if "pipe" in model: - pipe = model["pipe"] + owned_by = m.get("owned_by", "unknown") + if "pipe" in m: + pipe = m["pipe"] break + model = { + "id": f"{custom_model.id}", + "name": custom_model.name, + "object": "model", + "created": custom_model.created_at, + "owned_by": owned_by, + "preset": True, + **({"pipe": pipe} if pipe is not None else {}), + } + + info = custom_model.model_dump() + if "params" in info: + # Remove params to avoid exposing sensitive info + del info["params"] + + model["info"] = info + + action_ids = [] + filter_ids = [] + if custom_model.meta: meta = custom_model.meta.model_dump() @@ -207,20 +230,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) if "filterIds" in meta: filter_ids.extend(meta["filterIds"]) - models.append( - { - "id": f"{custom_model.id}", - "name": custom_model.name, - "object": "model", - "created": custom_model.created_at, - "owned_by": owned_by, - "info": custom_model.model_dump(), - "preset": True, - **({"pipe": pipe} if pipe is not None else {}), - "action_ids": action_ids, - "filter_ids": filter_ids, - } - ) + model["action_ids"] = action_ids + model["filter_ids"] = filter_ids + + models.append(model) # Process action_ids to get the actions def get_action_items_from_module(function, module): diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index e0bf7582c6..392f4cd4bf 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1,4 +1,5 @@ import base64 +import copy import hashlib import logging import mimetypes @@ -41,6 +42,7 @@ ENABLE_OAUTH_GROUP_MANAGEMENT, ENABLE_OAUTH_GROUP_CREATION, OAUTH_BLOCKED_GROUPS, + OAUTH_GROUPS_SEPARATOR, OAUTH_ROLES_CLAIM, OAUTH_SUB_CLAIM, OAUTH_GROUPS_CLAIM, @@ -74,6 +76,8 @@ OAuthMetadata, ) +from authlib.oauth2.rfc6749.errors import OAuth2Error + class OAuthClientInformationFull(OAuthClientMetadata): issuer: Optional[str] = None # URL of the OAuth server that issued this client @@ -150,6 +154,37 @@ def decrypt_data(data: str): raise +def _build_oauth_callback_error_message(e: Exception) -> str: + """ + Produce a user-facing callback error string with actionable context. + Keeps the message short and strips newlines for safe redirect usage. + """ + if isinstance(e, OAuth2Error): + parts = [p for p in [e.error, e.description] if p] + detail = " - ".join(parts) + elif isinstance(e, HTTPException): + detail = e.detail if isinstance(e.detail, str) else str(e.detail) + elif isinstance(e, aiohttp.ClientResponseError): + detail = f"Upstream provider returned {e.status}: {e.message}" + elif isinstance(e, aiohttp.ClientError): + detail = str(e) + elif isinstance(e, KeyError): + missing = str(e).strip("'") + if missing.lower() == "state": + detail = "Missing state parameter in callback (session may have expired)" + else: + detail = f"Missing expected key '{missing}' in OAuth response" + else: + detail = str(e) + + detail = detail.replace("\n", " ").strip() + if not detail: + detail = e.__class__.__name__ + + message = f"OAuth callback failed: {detail}" + return message[:197] + "..." if len(message) > 200 else message + + def is_in_blocked_groups(group_name: str, groups: list) -> bool: """ Check if a group name matches any blocked pattern. @@ -251,7 +286,7 @@ async def get_oauth_client_info_with_dynamic_client_registration( # Attempt to fetch OAuth server metadata to get registration endpoint & scopes discovery_urls = get_discovery_urls(oauth_server_url) for url in discovery_urls: - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( url, ssl=AIOHTTP_CLIENT_SESSION_SSL ) as oauth_server_metadata_response: @@ -287,7 +322,7 @@ async def get_oauth_client_info_with_dynamic_client_registration( ) # Perform dynamic client registration and return client info - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: async with session.post( registration_url, json=registration_data, ssl=AIOHTTP_CLIENT_SESSION_SSL ) as oauth_client_registration_response: @@ -371,6 +406,82 @@ def remove_client(self, client_id): if client_id in self.clients: del self.clients[client_id] log.info(f"Removed OAuth client {client_id}") + + if hasattr(self.oauth, "_clients"): + if client_id in self.oauth._clients: + self.oauth._clients.pop(client_id, None) + + if hasattr(self.oauth, "_registry"): + if client_id in self.oauth._registry: + self.oauth._registry.pop(client_id, None) + + return True + + async def _preflight_authorization_url( + self, client, client_info: OAuthClientInformationFull + ) -> bool: + # TODO: Replace this logic with a more robust OAuth client registration validation + # Only perform preflight checks for Starlette OAuth clients + if not hasattr(client, "create_authorization_url"): + return True + + redirect_uri = None + if client_info.redirect_uris: + redirect_uri = str(client_info.redirect_uris[0]) + + try: + auth_data = await client.create_authorization_url(redirect_uri=redirect_uri) + authorization_url = auth_data.get("url") + + if not authorization_url: + return True + except Exception as e: + log.debug( + f"Skipping OAuth preflight for client {client_info.client_id}: {e}", + ) + return True + + try: + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.get( + authorization_url, + allow_redirects=False, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as resp: + if resp.status < 400: + return True + response_text = await resp.text() + + error = None + error_description = "" + + content_type = resp.headers.get("content-type", "") + if "application/json" in content_type: + try: + payload = json.loads(response_text) + error = payload.get("error") + error_description = payload.get("error_description", "") + except: + pass + else: + error_description = response_text + + error_message = f"{error or ''} {error_description or ''}".lower() + + if any( + keyword in error_message + for keyword in ("invalid_client", "invalid client", "client id") + ): + log.warning( + f"OAuth client preflight detected invalid registration for {client_info.client_id}: {error} {error_description}" + ) + + return False + except Exception as e: + log.debug( + f"Skipping OAuth preflight network check for client {client_info.client_id}: {e}" + ) + return True def get_client(self, client_id): @@ -561,7 +672,6 @@ async def handle_authorize(self, request, client_id: str) -> RedirectResponse: client = self.get_client(client_id) if client is None: raise HTTPException(404) - client_info = self.get_client_info(client_id) if client_info is None: raise HTTPException(404) @@ -569,7 +679,8 @@ async def handle_authorize(self, request, client_id: str) -> RedirectResponse: redirect_uri = ( client_info.redirect_uris[0] if client_info.redirect_uris else None ) - return await client.authorize_redirect(request, str(redirect_uri)) + redirect_uri_str = str(redirect_uri) if redirect_uri else None + return await client.authorize_redirect(request, redirect_uri_str) async def handle_callback(self, request, client_id: str, user_id: str, response): client = self.get_client(client_id) @@ -621,8 +732,14 @@ async def handle_callback(self, request, client_id: str, user_id: str, response) error_message = "Failed to obtain OAuth token" log.warning(error_message) except Exception as e: - error_message = "OAuth callback error" - log.warning(f"OAuth callback error: {e}") + error_message = _build_oauth_callback_error_message(e) + log.warning( + "OAuth callback error for user_id=%s client_id=%s: %s", + user_id, + client_id, + error_message, + exc_info=True, + ) redirect_url = ( str(request.app.state.config.WEBUI_URL or request.base_url) @@ -630,7 +747,9 @@ async def handle_callback(self, request, client_id: str, user_id: str, response) if error_message: log.debug(error_message) - redirect_url = f"{redirect_url}/?error={error_message}" + redirect_url = ( + f"{redirect_url}/?error={urllib.parse.quote_plus(error_message)}" + ) return RedirectResponse(url=redirect_url, headers=response.headers) response = RedirectResponse(url=redirect_url, headers=response.headers) @@ -917,7 +1036,11 @@ def update_user_groups(self, user, user_data, default_permissions): if isinstance(claim_data, list): user_oauth_groups = claim_data elif isinstance(claim_data, str): - user_oauth_groups = [claim_data] + # Split by the configured separator if present + if OAUTH_GROUPS_SEPARATOR in claim_data: + user_oauth_groups = claim_data.split(OAUTH_GROUPS_SEPARATOR) + else: + user_oauth_groups = [claim_data] else: user_oauth_groups = [] @@ -1104,7 +1227,13 @@ async def handle_callback(self, request, provider, response): try: token = await client.authorize_access_token(request) except Exception as e: - log.warning(f"OAuth callback error: {e}") + detailed_error = _build_oauth_callback_error_message(e) + log.warning( + "OAuth callback error during authorize_access_token for provider %s: %s", + provider, + detailed_error, + exc_info=True, + ) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) # Try to get userinfo from the token first, some providers include it there diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 4a431dcab3..bf372e0e76 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -297,6 +297,10 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: if "tools" in openai_payload: ollama_payload["tools"] = openai_payload["tools"] + if "max_tokens" in openai_payload: + ollama_payload["num_predict"] = openai_payload["max_tokens"] + del openai_payload["max_tokens"] + # If there are advanced parameters in the payload, format them in Ollama's options field if openai_payload.get("options"): ollama_payload["options"] = openai_payload["options"] diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 16f50dfb20..1d1254f184 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -85,9 +85,26 @@ async def new_function(*args, **kwargs): update_wrapper(new_function, function) new_function.__signature__ = new_sig + new_function.__function__ = function # type: ignore + new_function.__extra_params__ = extra_params # type: ignore + return new_function +def get_updated_tool_function(function: Callable, extra_params: dict): + # Get the original function and merge updated params + __function__ = getattr(function, "__function__", None) + __extra_params__ = getattr(function, "__extra_params__", None) + + if __function__ is not None and __extra_params__ is not None: + return get_async_tool_function_and_apply_extra_params( + __function__, + {**__extra_params__, **extra_params}, + ) + + return function + + async def get_tools( request: Request, tool_ids: list[str], user: UserModel, extra_params: dict ) -> dict[str, dict]: diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py index 7ea29f3988..4424c651ac 100644 --- a/backend/open_webui/utils/webhook.py +++ b/backend/open_webui/utils/webhook.py @@ -51,7 +51,7 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b payload = {**event_data} log.debug(f"payload: {payload}") - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: async with session.post(url, json=payload) as r: r_text = await r.text() r.raise_for_status() diff --git a/backend/requirements.txt b/backend/requirements.txt index 21225b1db1..ed78e4b226 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,7 +5,7 @@ python-multipart==0.0.20 itsdangerous==2.2.0 python-socketio==5.13.0 -python-jose==3.4.0 +python-jose==3.5.0 cryptography bcrypt==5.0.0 argon2-cffi==25.1.0 @@ -63,7 +63,7 @@ fpdf2==2.8.2 pymdown-extensions==10.14.2 docx2txt==0.8 python-pptx==1.0.2 -unstructured==0.16.17 +unstructured==0.18.15 nltk==3.9.1 Markdown==3.9 pypandoc==1.15 @@ -134,7 +134,7 @@ pytest-docker~=3.1.1 ldap3==2.9.1 ## Firecrawl -firecrawl-py==1.12.0 +firecrawl-py==4.5.0 ## Trace opentelemetry-api==1.37.0 diff --git a/docker-compose.yaml b/docker-compose.yaml index 74249febd9..349734a939 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,8 +11,6 @@ services: open-webui: build: context: . - args: - OLLAMA_BASE_URL: '/ollama' dockerfile: Dockerfile image: ghcr.io/open-webui/open-webui:${WEBUI_DOCKER_TAG-main} container_name: open-webui diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index ec8a79bbce..5b37c8f8c8 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -24,6 +24,10 @@ Noticed something off? Have an idea? Check our [Issues tab](https://github.com/o > - **Template Compliance:** Please be aware that failure to follow the provided issue template, or not providing the requested information at all, will likely result in your issue being closed without further consideration. This approach is critical for maintaining the manageability and integrity of issue tracking. > - **Detail is Key:** To ensure your issue is understood and can be effectively addressed, it's imperative to include comprehensive details. Descriptions should be clear, including steps to reproduce, expected outcomes, and actual results. Lack of sufficient detail may hinder our ability to resolve your issue. +> [!WARNING] +> Reporting vulnerabilities is not wanted through Issues! +> Instead, [use the security reporting functionality](https://github.com/open-webui/open-webui/security) and ensure you comply with the outlined requirements. + ### 🧭 Scope of Support We've noticed an uptick in issues not directly related to Open WebUI but rather to the environment it's run in, especially Docker setups. While we strive to support Docker deployment, understanding Docker fundamentals is crucial for a smooth experience. @@ -32,6 +36,8 @@ We've noticed an uptick in issues not directly related to Open WebUI but rather - **Advanced Configurations**: Setting up reverse proxies for HTTPS and managing Docker deployments requires foundational knowledge. There are numerous online resources available to learn these skills. Ensuring you have this knowledge will greatly enhance your experience with Open WebUI and similar projects. +- **Check the documentation and help improve it**: [Our documentation](https://docs.openwebui.com) has ever growing troubleshooting guides and detailed installation tutorials. Please verify if it is of help to your issue and help expand it by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs). + ## 💡 Contributing Looking to contribute? Great! Here's how you can help: @@ -46,9 +52,15 @@ We welcome pull requests. Before submitting one, please: 4. Write clear, descriptive commit messages. 5. It's essential to complete your pull request in a timely manner. We move fast, and having PRs hang around too long is not feasible. If you can't get it done within a reasonable time frame, we may have to close it to keep the project moving forward. +> [!NOTE] +> The Pull Request Template has various requirements outlined. Go through the PR-checklist one by one and ensure you completed all steps before submitting your PR for review (you can open it as draft otherwise!). + ### 📚 Documentation & Tutorials -Help us make Open WebUI more accessible by improving documentation, writing tutorials, or creating guides on setting up and optimizing the web UI. +Help us make Open WebUI more accessible by improving the documentation, writing tutorials, or creating guides on setting up and optimizing the Web UI. + +Help expand our documentation by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs). +We welcome tutorials, guides and other documentation improvements! ### 🌐 Translations and Internationalization @@ -62,9 +74,12 @@ To add a new language: - Copy the American English translation file(s) (from `en-US` directory in `src/lib/i18n/locale`) to this new directory and update the string values in JSON format according to your language. Make sure to preserve the structure of the JSON object. - Add the language code and its respective title to languages file at `src/lib/i18n/locales/languages.json`. +> [!NOTE] +> When adding new translations, do so in a standalone PR! Feature PRs or PRs fixing a bug should not contain translation updates. Always keep the scope of a PR narrow. + ### 🤔 Questions & Feedback -Got questions or feedback? Join our [Discord community](https://discord.gg/5rJgQTnV4s) or open an issue. We're here to help! +Got questions or feedback? Join our [Discord community](https://discord.gg/5rJgQTnV4s) or open an issue or discussion. We're here to help! ## 🙏 Thank You! diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f08c465471..90951e5265 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -44,8 +44,9 @@ We appreciate the community's interest in identifying potential vulnerabilities. > - Screenshots/videos demonstrating the exploit (supplementary to written steps) > > **Failure to provide a reproducible PoC may lead to closure of the report** -> We will notify you, if we struggle to reproduce the exploit using your PoC to allow you to improve your PoC -> However, if we repeatedly cannot reproduce the exploit using the PoC, the report may be closed +> +> We will notify you, if we struggle to reproduce the exploit using your PoC to allow you to improve your PoC. +> However, if we repeatedly cannot reproduce the exploit using the PoC, the report may be closed. 5. **Required Patch or Actionable Remediation Plan Submission**: Along with the PoC, reporters must provide a patch or some actionable steps to remediate the identified vulnerability. This helps us evaluate and implement fixes rapidly. @@ -56,22 +57,28 @@ We appreciate the community's interest in identifying potential vulnerabilities. > [!NOTE] > **Note**: If you believe you have found a security issue that > -> 1. affects default configurations **or** -> 2. represents a genuine bypass of intended security controls **or** -> 3. works only with non-default configurations **but the configuration in question is likely to be used by production deployments** > **then we absolutely want to hear about it.** This policy is intended to filter configuration issues and deployment problems, not to discourage legitimate security research. +> 1. affects default configurations, **or** +> 2. represents a genuine bypass of intended security controls, **or** +> 3. works only with non-default configurations, **but the configuration in question is likely to be used by production deployments**, **then we absolutely want to hear about it.** This policy is intended to filter configuration issues and deployment problems, not to discourage legitimate security research. 8. **Threat Model Understanding Required**: Reports must demonstrate understanding of Open WebUI's self-hosted, authenticated, role-based access control architecture. Comparing Open WebUI to services with fundamentally different security models without acknowledging the architectural differences may result in report rejection. 9. **CVSS Scoring Accuracy:** If you include a CVSS score with your report, it must accurately reflect the vulnerability according to CVSS methodology. Common errors include 1) rating PR:N (None) when authentication is required, 2) scoring hypothetical attack chains instead of the actual vulnerability, or 3) inflating severity without evidence. **We will adjust inaccurate CVSS scores.** Intentionally inflated scores may result in report rejection. -> [!WARNING] > **Using CVE Precedents:** If you cite other CVEs to support your report, ensure they are **genuinely comparable** in vulnerability type, threat model, and attack vector. Citing CVEs from different product categories, different vulnerability classes or different deployment models will lead us to suspect the use of AI in your report. +> [!WARNING] +> +> **Using CVE Precedents:** If you cite other CVEs to support your report, ensure they are **genuinely comparable** in vulnerability type, threat model, and attack vector. Citing CVEs from different product categories, different vulnerability classes or different deployment models will lead us to suspect the use of AI in your report. + +10. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. Admins have full system control and are expected to understand the security implications of their actions and configurations. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.** -11. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. Admins have full system control and are expected to understand the security implications of their actions and configurations. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.** +> [!NOTE] +> Similar to rule "Default Configuration Testing": If you believe you have found a vulnerability that affects admins and is NOT caused by admin negligence or intentionally malicious actions, +> **then we absolutely want to hear about it.** This policy is intended to filter social engineering attacks on admins, malicious plugins being deployed by admins and similar malicious actions, not to discourage legitimate security research. -12. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **YOU MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it. +11. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **YOU MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it. > [!NOTE] -> AI-aided vulnerability reports **will not be rejected by us by default.** But: +> AI-aided vulnerability reports **will not be rejected by us by default**. But: > > - If we suspect you used AI (but you did not disclose it to us), we will be asking tough follow-up questions to validate your understanding of the reported vulnerability and Open WebUI itself. > - If we suspect you used AI (but you did not disclose it to us) **and** your report ends up being invalid/not a vulnerability/not reproducible, then you **may be banned** from reporting future vulnerabilities. @@ -88,11 +95,21 @@ We appreciate the community's interest in identifying potential vulnerabilities. **Non-compliant submissions will be closed, and repeat extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users. +## Where to report the vulnerability + If you want to report a vulnerability and can meet the outlined requirements, [open a vulnerability report here](https://github.com/open-webui/open-webui/security/advisories/new). +If you feel like you are not able to follow ALL outlined requirements for vulnerability-specific reasons, still do report it, we will check every report either way. + +## Product Security And For Non-Vulnerability Related Security Concerns: -## Product Security And For Non-Vulnerability Security Concerns: +If your concern does not meet the vulnerability requirements outlined above, is not a vulnerability, **but is still related to security concerns**, then use the following channels instead: + +- **Documentation issues/improvement ideas:** Open an issue on our [Documentation Repository](https://github.com/open-webui/docs) +- **Feature requests:** Create a discussion in [GitHub Discussions - Ideas](https://github.com/open-webui/open-webui/discussions/) to discuss with the community if this feature request is wanted by multiple people +- **Configuration help:** Ask the community for help and guidance on our [Discord Server](https://discord.gg/5rJgQTnV4s) or on [Reddit](https://www.reddit.com/r/OpenWebUI/) +- **General issues:** Use our [Issue Tracker](https://github.com/open-webui/open-webui/issues) -If your concern does not meet the vulnerability requirements outlined above, such as: +**Examples of non-vulnerability, still security related concerns:** - Suggestions for better default configuration values - Security hardening recommendations @@ -102,12 +119,7 @@ If your concern does not meet the vulnerability requirements outlined above, suc - Feature requests for optional security enhancements (2FA, audit logging, etc.) - General security questions about production deployment -**then use one of the following channels instead:** - -- **Documentation issues/improvement ideas:** Open an issue on our [Documentation Repository](https://github.com/open-webui/docs) -- **Feature requests:** Create a discussion in [GitHub Discussions - Ideas](https://github.com/open-webui/open-webui/discussions/) to discuss with the community if this feature request is wanted by multiple people -- **Configuration help:** Ask the community for help and guidance on our [Discord Server](https://discord.gg/5rJgQTnV4s) or on [Reddit](https://www.reddit.com/r/OpenWebUI/) -- **General issues:** Use our [Issue Tracker](https://github.com/open-webui/open-webui/issues) +Please use the adequate channel for your specific issue - e.g. best-practice guidance or additional documentation needs into the Documentation Repository, and feature requests into the Main Repository as an issue or discussion. We regularly audit our internal processes and system architecture for vulnerabilities using a combination of automated and manual testing techniques. We are also planning to implement SAST and SCA scans in our project soon. @@ -115,4 +127,4 @@ For any other immediate concerns, please create an issue in our [issue tracker]( --- -_Last updated on **2025-10-12**._ +_Last updated on **2025-11-06**._ diff --git a/package-lock.json b/package-lock.json index b14325a9dc..071a81bc7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.6.34.1", + "version": "0.6.36.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.6.34.1", + "version": "0.6.36.1", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -105,8 +105,8 @@ "devDependencies": { "@sveltejs/adapter-auto": "3.2.2", "@sveltejs/adapter-static": "^3.0.2", - "@sveltejs/kit": "^2.20.6", - "@sveltejs/vite-plugin-svelte": "^3.1.1", + "@sveltejs/kit": "^2.5.27", + "@sveltejs/vite-plugin-svelte": "^4.0.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/postcss": "^4.0.0", "@tailwindcss/typography": "^0.5.13", @@ -116,14 +116,14 @@ "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-cypress": "^3.4.0", - "eslint-plugin-svelte": "^2.43.0", + "eslint-plugin-svelte": "^2.45.1", "i18next-parser": "^9.0.1", "postcss": "^8.4.31", "prettier": "^3.3.3", "prettier-plugin-svelte": "^3.2.6", "sass-embedded": "^1.81.0", - "svelte": "^4.2.18", - "svelte-check": "^3.8.5", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", "svelte-confetti": "^1.3.2", "tailwindcss": "^4.0.0", "tslib": "^2.4.1", @@ -157,18 +157,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@antfu/install-pkg": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.0.0.tgz", @@ -1999,16 +1987,23 @@ "license": "MIT" }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -2019,18 +2014,11 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -2212,23 +2200,6 @@ "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==" }, - "node_modules/@melt-ui/svelte": { - "version": "0.76.2", - "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz", - "integrity": "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.3.1", - "@floating-ui/dom": "^1.4.5", - "@internationalized/date": "^3.5.0", - "dequal": "^2.0.3", - "focus-trap": "^7.5.2", - "nanoid": "^5.0.4" - }, - "peerDependencies": { - "svelte": ">=3 <5" - } - }, "node_modules/@mermaid-js/parser": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz", @@ -2950,42 +2921,89 @@ "license": "LIL" }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.1.tgz", - "integrity": "sha512-rimpFEAboBBHIlzISibg94iP09k/KYdHgVhJlcsTfn7KMBhc70jFX/GRWkRdFCc2fdnk+4+Bdfej23cMDnJS6A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", + "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", + "license": "MIT", "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", - "debug": "^4.3.4", + "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", + "debug": "^4.3.7", "deepmerge": "^4.3.1", "kleur": "^4.1.5", - "magic-string": "^0.30.10", - "svelte-hmr": "^0.16.0", - "vitefu": "^0.2.5" + "magic-string": "^0.30.12", + "vitefu": "^1.0.3" }, "engines": { - "node": "^18.0.0 || >=20" + "node": "^18.0.0 || ^20.0.0 || >=22" }, "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", "vite": "^5.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz", - "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", + "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", + "license": "MIT", "dependencies": { - "debug": "^4.3.4" + "debug": "^4.3.7" }, "engines": { - "node": "^18.0.0 || >=20" + "node": "^18.0.0 || ^20.0.0 || >=22" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", "vite": "^5.0.0" } }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -4200,12 +4218,6 @@ "undici-types": "~5.26.4" } }, - "node_modules/@types/pug": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", - "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", - "dev": true - }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -4805,11 +4817,12 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dependencies": { - "dequal": "^2.0.3" + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" } }, "node_modules/asn1": { @@ -4897,11 +4910,12 @@ "dev": true }, "node_modules/axobject-query": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz", - "integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==", - "dependencies": { - "dequal": "^2.0.3" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" } }, "node_modules/balanced-match": { @@ -4992,6 +5006,23 @@ "svelte": "^4.0.0 || ^5.0.0-next.118" } }, + "node_modules/bits-ui/node_modules/@melt-ui/svelte": { + "version": "0.76.2", + "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz", + "integrity": "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.3.1", + "@floating-ui/dom": "^1.4.5", + "@internationalized/date": "^3.5.0", + "dequal": "^2.0.3", + "focus-trap": "^7.5.2", + "nanoid": "^5.0.4" + }, + "peerDependencies": { + "svelte": ">=3 <5" + } + }, "node_modules/bl": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", @@ -5704,24 +5735,13 @@ "integrity": "sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag==", "dev": true }, - "node_modules/code-red": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", - "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15", - "@types/estree": "^1.0.1", - "acorn": "^8.10.0", - "estree-walker": "^3.0.3", - "periscopic": "^3.1.0" - } - }, - "node_modules/code-red/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dependencies": { - "@types/estree": "^1.0.0" + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/codedent": { @@ -5983,18 +6003,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", - "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", @@ -6817,15 +6825,6 @@ "node": ">=6" } }, - "node_modules/detect-indent": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", - "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -7134,12 +7133,6 @@ "node": ">= 0.4" } }, - "node_modules/es6-promise": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", - "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", - "dev": true - }, "node_modules/esbuild": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", @@ -7296,22 +7289,23 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "2.43.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.43.0.tgz", - "integrity": "sha512-REkxQWvg2pp7QVLxQNa+dJ97xUqRe7Y2JJbSWkHSuszu0VcblZtXkPBPckkivk99y5CdLw4slqfPylL2d/X4jQ==", + "version": "2.46.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.46.1.tgz", + "integrity": "sha512-7xYr2o4NID/f9OEYMqxsEQsCsj4KaMy4q5sANaKkAb6/QeCjYFxRmDm2S3YC3A3pl1kyPZ/syOx/i7LcWYSbIw==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@jridgewell/sourcemap-codec": "^1.4.15", "eslint-compat-utils": "^0.5.1", "esutils": "^2.0.3", - "known-css-properties": "^0.34.0", + "known-css-properties": "^0.35.0", "postcss": "^8.4.38", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^6.0.0", "postcss-selector-parser": "^6.1.0", "semver": "^7.6.2", - "svelte-eslint-parser": "^0.41.0" + "svelte-eslint-parser": "^0.43.0" }, "engines": { "node": "^14.17.0 || >=16.0.0" @@ -7321,7 +7315,7 @@ }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0-0 || ^9.0.0-0", - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0-next.191" + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "svelte": { @@ -7428,6 +7422,15 @@ "node": ">=0.10" } }, + "node_modules/esrap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.1.tgz", + "integrity": "sha512-ebTT9B6lOtZGMgJ3o5r12wBacHctG7oEWazIda8UlPfA3HD/Wrv8FdXoVo73vzdpwCxNyXjPauyN2bbJzMkB9A==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -9045,10 +9048,11 @@ } }, "node_modules/known-css-properties": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.34.0.tgz", - "integrity": "sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ==", - "dev": true + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz", + "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", + "dev": true, + "license": "MIT" }, "node_modules/kokoro-js": { "version": "1.1.1", @@ -9682,11 +9686,12 @@ "license": "ISC" }, "node_modules/magic-string": { - "version": "0.30.11", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", - "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/markdown-it": { @@ -9762,11 +9767,6 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==" - }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -9881,15 +9881,6 @@ "node": ">=6" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -9985,18 +9976,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mktemp": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/mktemp/-/mktemp-0.4.0.tgz", @@ -10485,32 +10464,6 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "devOptional": true }, - "node_modules/periscopic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", - "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^3.0.0", - "is-reference": "^3.0.0" - } - }, - "node_modules/periscopic/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/periscopic/node_modules/is-reference": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", - "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", - "dependencies": { - "@types/estree": "*" - } - }, "node_modules/phonemizer": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/phonemizer/-/phonemizer-1.2.1.tgz", @@ -10708,6 +10661,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "engines": { "node": ">=12.0" }, @@ -11605,73 +11559,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/sander": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz", - "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==", - "dev": true, - "dependencies": { - "es6-promise": "^3.1.2", - "graceful-fs": "^4.1.3", - "mkdirp": "^0.5.1", - "rimraf": "^2.5.2" - } - }, - "node_modules/sander/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/sander/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sander/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/sander/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/sass-embedded": { "version": "1.81.0", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.81.0.tgz", @@ -12255,21 +12142,6 @@ "node": ">=10.0.0" } }, - "node_modules/sorcery": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", - "integrity": "sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.14", - "buffer-crc32": "^0.2.5", - "minimist": "^1.2.0", - "sander": "^0.5.0" - }, - "bin": { - "sorcery": "bin/sorcery" - } - }, "node_modules/sort-keys": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-5.0.0.tgz", @@ -12480,18 +12352,6 @@ "node": ">=6" } }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -12551,47 +12411,115 @@ } }, "node_modules/svelte": { - "version": "4.2.19", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", - "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", + "version": "5.42.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.2.tgz", + "integrity": "sha512-iSry5jsBHispVczyt9UrBX/1qu3HQ/UyKPAIjqlvlu3o/eUvc+kpyMyRS2O4HLLx4MvLurLGIUOyyP11pyD59g==", + "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.1", - "@jridgewell/sourcemap-codec": "^1.4.15", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/estree": "^1.0.1", - "acorn": "^8.9.0", - "aria-query": "^5.3.0", - "axobject-query": "^4.0.0", - "code-red": "^1.0.3", - "css-tree": "^2.3.1", - "estree-walker": "^3.0.3", - "is-reference": "^3.0.1", + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "esm-env": "^1.2.1", + "esrap": "^2.1.0", + "is-reference": "^3.0.3", "locate-character": "^3.0.0", - "magic-string": "^0.30.4", - "periscopic": "^3.1.0" + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/svelte-check": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.5.tgz", - "integrity": "sha512-3OGGgr9+bJ/+1nbPgsvulkLC48xBsqsgtc8Wam281H4G9F5v3mYGa2bHRsPuwHC5brKl4AxJH95QF73kmfihGQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz", + "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", - "chokidar": "^3.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", "picocolors": "^1.0.0", - "sade": "^1.7.4", - "svelte-preprocess": "^5.1.3", - "typescript": "^5.0.3" + "sade": "^1.7.4" }, "bin": { "svelte-check": "bin/svelte-check" }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-check/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/svelte-check/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { - "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/svelte-check/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/svelte-confetti": { @@ -12604,10 +12532,11 @@ } }, "node_modules/svelte-eslint-parser": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.0.tgz", - "integrity": "sha512-L6f4hOL+AbgfBIB52Z310pg1d2QjRqm7wy3kI1W6hhdhX5bvu7+f0R6w4ykp5HoDdzq+vGhIJmsisaiJDGmVfA==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", + "integrity": "sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==", "dev": true, + "license": "MIT", "dependencies": { "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", @@ -12622,7 +12551,7 @@ "url": "https://github.com/sponsors/ota-meshi" }, "peerDependencies": { - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0-next.191" + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "svelte": { @@ -12630,80 +12559,6 @@ } } }, - "node_modules/svelte-hmr": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", - "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==", - "engines": { - "node": "^12.20 || ^14.13.1 || >= 16" - }, - "peerDependencies": { - "svelte": "^3.19.0 || ^4.0.0" - } - }, - "node_modules/svelte-preprocess": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.3.tgz", - "integrity": "sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@types/pug": "^2.0.6", - "detect-indent": "^6.1.0", - "magic-string": "^0.30.5", - "sorcery": "^0.11.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">= 16.0.0", - "pnpm": "^8.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.10.2", - "coffeescript": "^2.5.1", - "less": "^3.11.3 || ^4.0.0", - "postcss": "^7 || ^8", - "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", - "pug": "^3.0.0", - "sass": "^1.26.8", - "stylus": "^0.55.0", - "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", - "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "coffeescript": { - "optional": true - }, - "less": { - "optional": true - }, - "postcss": { - "optional": true - }, - "postcss-load-config": { - "optional": true - }, - "pug": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, "node_modules/svelte-sonner": { "version": "0.3.28", "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-0.3.28.tgz", @@ -12712,20 +12567,19 @@ "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" } }, - "node_modules/svelte/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dependencies": { - "@types/estree": "^1.0.0" - } + "node_modules/svelte/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" }, "node_modules/svelte/node_modules/is-reference": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", - "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", "dependencies": { - "@types/estree": "*" + "@types/estree": "^1.0.6" } }, "node_modules/svg-pathdata": { @@ -14237,11 +14091,17 @@ } }, "node_modules/vitefu": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", - "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "peerDependenciesMeta": { "vite": { @@ -14970,6 +14830,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + }, "node_modules/zrender": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", diff --git a/package.json b/package.json index 9160d0de54..8135372d65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.6.34.1", + "version": "0.6.36.1", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -24,8 +24,8 @@ "devDependencies": { "@sveltejs/adapter-auto": "3.2.2", "@sveltejs/adapter-static": "^3.0.2", - "@sveltejs/kit": "^2.20.6", - "@sveltejs/vite-plugin-svelte": "^3.1.1", + "@sveltejs/kit": "^2.5.27", + "@sveltejs/vite-plugin-svelte": "^4.0.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/postcss": "^4.0.0", "@tailwindcss/typography": "^0.5.13", @@ -35,14 +35,14 @@ "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-cypress": "^3.4.0", - "eslint-plugin-svelte": "^2.43.0", + "eslint-plugin-svelte": "^2.45.1", "i18next-parser": "^9.0.1", "postcss": "^8.4.31", "prettier": "^3.3.3", "prettier-plugin-svelte": "^3.2.6", "sass-embedded": "^1.81.0", - "svelte": "^4.2.18", - "svelte-check": "^3.8.5", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", "svelte-confetti": "^1.3.2", "tailwindcss": "^4.0.0", "tslib": "^2.4.1", diff --git a/pyproject.toml b/pyproject.toml index cd5a08fba2..87e88a1b06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "itsdangerous==2.2.0", "python-socketio==5.13.0", - "python-jose==3.4.0", + "python-jose==3.5.0", "cryptography", "bcrypt==5.0.0", "argon2-cffi==25.1.0", @@ -73,7 +73,7 @@ dependencies = [ "pymdown-extensions==10.14.2", "docx2txt==0.8", "python-pptx==1.0.2", - "unstructured==0.16.17", + "unstructured==0.18.15", "nltk==3.9.1", "Markdown==3.9", "pypandoc==1.15", @@ -151,9 +151,7 @@ all = [ "oracledb==3.2.0", "colbert-ai==0.2.21", - - "firecrawl-py==1.12.0", - "tencentcloud-sdk-python==3.0.1336", + "firecrawl-py==4.5.0", ] [project.scripts] diff --git a/src/app.css b/src/app.css index 49bcebbd97..f4e3225d3b 100644 --- a/src/app.css +++ b/src/app.css @@ -129,8 +129,8 @@ li p { } ::-webkit-scrollbar { - height: 0.8rem; - width: 0.8rem; + height: 0.45rem; + width: 0.45rem; } ::-webkit-scrollbar-track { @@ -152,6 +152,14 @@ select { -webkit-appearance: none; } +.dark select:not([class*='bg-transparent']) { + @apply bg-gray-900 text-gray-300; +} + +.dark select option { + @apply bg-gray-850 text-white; +} + @keyframes shimmer { 0% { background-position: 200% 0; diff --git a/src/app.html b/src/app.html index c6b24d28b3..0edd49feb7 100644 --- a/src/app.html +++ b/src/app.html @@ -23,12 +23,7 @@ href="/static/apple-touch-icon.png" crossorigin="use-credentials" /> - + { return res; }; +export const getVersion = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_BASE_URL}/api/version`, { + 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.error(err); + error = err; + return null; + }); + + if (error) { + throw error; + } + + return res?.version ?? null; +}; + export const getVersionUpdates = async (token: string) => { let error = null; diff --git a/src/lib/apis/models/index.ts b/src/lib/apis/models/index.ts index d324fa9173..32a7151b5d 100644 --- a/src/lib/apis/models/index.ts +++ b/src/lib/apis/models/index.ts @@ -1,9 +1,9 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; -export const getModels = async (token: string = '') => { +export const getModelItems = async (token: string = '') => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/models/`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/models/list`, { method: 'GET', headers: { Accept: 'application/json', diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte index 4bbcebd15f..bd438257cb 100644 --- a/src/lib/components/admin/Functions.svelte +++ b/src/lib/components/admin/Functions.svelte @@ -158,6 +158,7 @@ if (res) { toast.success($i18n.t('Function deleted successfully')); + functions = functions.filter((f) => f.id !== func.id); _functions.set(await getFunctions(localStorage.token)); models.set( diff --git a/src/lib/components/admin/Settings/Audio.svelte b/src/lib/components/admin/Settings/Audio.svelte index dcd7ad3029..863cbe34cc 100644 --- a/src/lib/components/admin/Settings/Audio.svelte +++ b/src/lib/components/admin/Settings/Audio.svelte @@ -50,6 +50,9 @@ let STT_AZURE_BASE_URL = ''; let STT_AZURE_MAX_SPEAKERS = ''; let STT_DEEPGRAM_API_KEY = ''; + let STT_MISTRAL_API_KEY = ''; + let STT_MISTRAL_API_BASE_URL = ''; + let STT_MISTRAL_USE_CHAT_COMPLETIONS = false; let STT_WHISPER_MODEL_LOADING = false; @@ -135,7 +138,10 @@ AZURE_REGION: STT_AZURE_REGION, AZURE_LOCALES: STT_AZURE_LOCALES, AZURE_BASE_URL: STT_AZURE_BASE_URL, - AZURE_MAX_SPEAKERS: STT_AZURE_MAX_SPEAKERS + AZURE_MAX_SPEAKERS: STT_AZURE_MAX_SPEAKERS, + MISTRAL_API_KEY: STT_MISTRAL_API_KEY, + MISTRAL_API_BASE_URL: STT_MISTRAL_API_BASE_URL, + MISTRAL_USE_CHAT_COMPLETIONS: STT_MISTRAL_USE_CHAT_COMPLETIONS } }); @@ -184,6 +190,9 @@ 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; + STT_MISTRAL_API_KEY = res.stt.MISTRAL_API_KEY; + STT_MISTRAL_API_BASE_URL = res.stt.MISTRAL_API_BASE_URL; + STT_MISTRAL_USE_CHAT_COMPLETIONS = res.stt.MISTRAL_USE_CHAT_COMPLETIONS; } await getVoices(); @@ -201,7 +210,7 @@
-
{$i18n.t('Speech-to-Text')}
+
{$i18n.t('Speech-to-Text')}

@@ -235,6 +244,7 @@ +
@@ -367,6 +377,67 @@
+ {:else if STT_ENGINE === 'mistral'} +
+
+ + + +
+
+ +
+ +
+
{$i18n.t('STT Model')}
+
+
+ +
+
+
+ {$i18n.t('Leave empty to use the default model (voxtral-mini-latest).')} + + {$i18n.t('Learn more about Voxtral transcription.')} + +
+
+ +
+ +
+
+
{$i18n.t('Use Chat Completions API')}
+ +
+
+ {$i18n.t( + 'Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.' + )} +
+
{:else if STT_ENGINE === ''}
{$i18n.t('STT Model')}
@@ -427,7 +498,7 @@
-
{$i18n.t('Text-to-Speech')}
+
{$i18n.t('Text-to-Speech')}

diff --git a/src/lib/components/admin/Settings/CodeExecution.svelte b/src/lib/components/admin/Settings/CodeExecution.svelte index 6050fb26bb..5838a2f4d1 100644 --- a/src/lib/components/admin/Settings/CodeExecution.svelte +++ b/src/lib/components/admin/Settings/CodeExecution.svelte @@ -41,7 +41,7 @@ {#if config}
-
{$i18n.t('General')}
+
{$i18n.t('General')}

@@ -164,7 +164,7 @@
-
{$i18n.t('Code Interpreter')}
+
{$i18n.t('Code Interpreter')}

diff --git a/src/lib/components/admin/Settings/Connections.svelte b/src/lib/components/admin/Settings/Connections.svelte index 100e440d5d..93cf755be8 100644 --- a/src/lib/components/admin/Settings/Connections.svelte +++ b/src/lib/components/admin/Settings/Connections.svelte @@ -219,7 +219,7 @@
{#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null && connectionsConfig !== null}
-
{$i18n.t('General')}
+
{$i18n.t('General')}

diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index efa63d89b9..1d55bf872f 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -171,14 +171,6 @@ return; } - if ( - RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker' && - !RAGConfig.DATALAB_MARKER_API_KEY - ) { - toast.error($i18n.t('Datalab Marker API Key required.')); - return; - } - if ( RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker' && RAGConfig.DATALAB_MARKER_ADDITIONAL_CONFIG && @@ -220,6 +212,15 @@ await embeddingModelUpdateHandler(); } + if (RAGConfig.MINERU_PARAMS) { + try { + JSON.parse(RAGConfig.MINERU_PARAMS); + } catch (e) { + toast.error($i18n.t('Invalid JSON format in MinerU Parameters')); + return; + } + } + const res = await updateRAGConfig(localStorage.token, { ...RAGConfig, ALLOWED_FILE_EXTENSIONS: RAGConfig.ALLOWED_FILE_EXTENSIONS.split(',') @@ -228,7 +229,13 @@ DOCLING_PICTURE_DESCRIPTION_LOCAL: JSON.parse( RAGConfig.DOCLING_PICTURE_DESCRIPTION_LOCAL || '{}' ), - DOCLING_PICTURE_DESCRIPTION_API: JSON.parse(RAGConfig.DOCLING_PICTURE_DESCRIPTION_API || '{}') + DOCLING_PICTURE_DESCRIPTION_API: JSON.parse( + RAGConfig.DOCLING_PICTURE_DESCRIPTION_API || '{}' + ), + MINERU_PARAMS: + typeof RAGConfig.MINERU_PARAMS === 'string' && RAGConfig.MINERU_PARAMS.trim() !== '' + ? JSON.parse(RAGConfig.MINERU_PARAMS) + : {} }); dispatch('save'); }; @@ -269,6 +276,11 @@ 2 ); + config.MINERU_PARAMS = + typeof config.MINERU_PARAMS === 'object' + ? JSON.stringify(config.MINERU_PARAMS ?? {}, null, 2) + : config.MINERU_PARAMS; + RAGConfig = config; }); @@ -325,7 +337,7 @@
-
{$i18n.t('General')}
+
{$i18n.t('General')}

@@ -733,7 +745,7 @@
{/if}
diff --git a/src/lib/components/layout/Navbar/Menu.svelte b/src/lib/components/layout/Navbar/Menu.svelte index 19170e5b7b..9d2ab76f66 100644 --- a/src/lib/components/layout/Navbar/Menu.svelte +++ b/src/lib/components/layout/Navbar/Menu.svelte @@ -19,7 +19,8 @@ user, settings, folders, - showEmbeds + showEmbeds, + artifactContents } from '$lib/stores'; import { flyAndScale } from '$lib/utils/transitions'; import { getChatById } from '$lib/apis/chats'; @@ -312,7 +313,7 @@
{$i18n.t('Settings')}
--> - {#if $mobile} + {#if $mobile && ($user?.role === 'admin' || ($user?.permissions.chat?.controls ?? true))} {$i18n.t('Overview')}
- { - await showControls.set(true); - await showArtifacts.set(true); - await showOverview.set(false); - await showEmbeds.set(false); - }} - > - -
{$i18n.t('Artifacts')}
-
+ {#if ($artifactContents ?? []).length > 0} + { + await showControls.set(true); + await showArtifacts.set(true); + await showOverview.set(false); + await showEmbeds.set(false); + }} + > + +
{$i18n.t('Artifacts')}
+
+ {/if}
diff --git a/src/lib/components/layout/SearchModal.svelte b/src/lib/components/layout/SearchModal.svelte index c69bbf6346..6aed01d145 100644 --- a/src/lib/components/layout/SearchModal.svelte +++ b/src/lib/components/layout/SearchModal.svelte @@ -56,12 +56,7 @@ } const loadChatPreview = async (selectedIdx) => { - if ( - !chatList || - chatList.length === 0 || - selectedIdx === null || - chatList[selectedIdx] === undefined - ) { + if (!chatList || chatList.length === 0 || selectedIdx === null) { selectedChat = null; messages = null; history = null; @@ -70,8 +65,11 @@ } const selectedChatIdx = selectedIdx - actions.length; - if (selectedChatIdx < 0) { + if (selectedChatIdx < 0 || selectedChatIdx >= chatList.length) { selectedChat = null; + messages = null; + history = null; + selectedModels = ['']; return; } @@ -233,7 +231,7 @@ { label: $i18n.t('Create a new note'), onClick: async () => { - await goto(`/notes${query ? `?content=${query}` : ''}`); + await goto(`/notes?content=${query}`); show = false; onClose(); }, diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 282909465e..c8d8a19018 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -62,6 +62,7 @@ import PinnedModelList from './Sidebar/PinnedModelList.svelte'; import Note from '../icons/Note.svelte'; import { slide } from 'svelte/transition'; + import HotkeyHint from '../common/HotkeyHint.svelte'; const BREAKPOINT = 768; @@ -128,7 +129,8 @@ }; const createFolder = async ({ name, data }) => { - if (name === '') { + name = name?.trim(); + if (!name) { toast.error($i18n.t('Folder name cannot be empty.')); return; } @@ -181,6 +183,7 @@ console.log('initChatList'); currentChatPage.set(1); allChatsLoaded = false; + scrollPaginationEnabled.set(false); initFolders(); await Promise.all([ @@ -366,10 +369,6 @@ navElement.style['-webkit-app-region'] = 'drag'; } } - - if (!$showSidebar && !value) { - showSidebar.set(true); - } }), showSidebar.subscribe(async (value) => { localStorage.sidebar = value; @@ -478,6 +477,12 @@ { + name = name?.trim(); + if (!name) { + toast.error($i18n.t('Channel name cannot be empty.')); + return; + } + const res = await createNewChannel(localStorage.token, { name: name, access_control: access_control @@ -743,7 +748,10 @@ -
+ @@ -787,7 +795,7 @@ -
-
+
{$i18n.t('Search')}
+
@@ -880,7 +891,14 @@
{#if ($models ?? []).length > 0 && ($settings?.pinnedModels ?? []).length > 0} - + + + {/if} {#if $config?.features?.enable_channels && ($user?.role === 'admin' || $channels.length > 0)} diff --git a/src/lib/components/layout/Sidebar/ChannelItem.svelte b/src/lib/components/layout/Sidebar/ChannelItem.svelte index 2d853931b5..4ea884cf9d 100644 --- a/src/lib/components/layout/Sidebar/ChannelItem.svelte +++ b/src/lib/components/layout/Sidebar/ChannelItem.svelte @@ -78,7 +78,7 @@ {#if $user?.role === 'admin'} - - +
{/if}
diff --git a/src/lib/components/layout/Sidebar/ChatMenu.svelte b/src/lib/components/layout/Sidebar/ChatMenu.svelte index 1a8ddad248..d6f3a3bfe0 100644 --- a/src/lib/components/layout/Sidebar/ChatMenu.svelte +++ b/src/lib/components/layout/Sidebar/ChatMenu.svelte @@ -393,7 +393,7 @@
{$i18n.t('Clone')}
- {#if chatId} + {#if chatId && $folders.length > 0}
{ mouseOver = true; @@ -29,7 +29,7 @@ }} >
-
-
+
@@ -377,7 +377,7 @@ Based on the user's instruction, update and enhance the existing notes or select
{#if selectedContent} -
+
{selectedContent?.text} diff --git a/src/lib/components/notes/NoteEditor/Controls.svelte b/src/lib/components/notes/NoteEditor/Controls.svelte index df988c28d9..675056ee41 100644 --- a/src/lib/components/notes/NoteEditor/Controls.svelte +++ b/src/lib/components/notes/NoteEditor/Controls.svelte @@ -17,7 +17,7 @@ }; -
+
-
+
{#if files.length > 0} -
{$i18n.t('Files')}
+
{$i18n.t('Files')}
{#each files.filter((file) => file.type !== 'image') as file, fileIdx} diff --git a/src/lib/components/notes/NotePanel.svelte b/src/lib/components/notes/NotePanel.svelte index 676d86b83d..96e687854e 100644 --- a/src/lib/components/notes/NotePanel.svelte +++ b/src/lib/components/notes/NotePanel.svelte @@ -99,7 +99,7 @@ {#if show}
diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 2e8e1882a7..14f1392625 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -36,6 +36,8 @@ import { createNewNote, deleteNoteById, getNotes } from '$lib/apis/notes'; import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils'; + import { downloadPdf } from './utils'; + import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import Search from '../icons/Search.svelte'; @@ -124,82 +126,18 @@ }; const downloadHandler = async (type) => { - console.log('downloadHandler', type); - console.log('selectedNote', selectedNote); - if (type === 'md') { + if (type === 'txt') { + const blob = new Blob([selectedNote.data.content.md], { type: 'text/plain' }); + saveAs(blob, `${selectedNote.title}.txt`); + } else if (type === 'md') { const blob = new Blob([selectedNote.data.content.md], { type: 'text/markdown' }); saveAs(blob, `${selectedNote.title}.md`); } else if (type === 'pdf') { - await downloadPdf(selectedNote); - } - }; - - const downloadPdf = async (note) => { - try { - const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([ - import('jspdf'), - import('html2canvas-pro') - ]); - - // Define a fixed virtual screen size - const virtualWidth = 1024; // Fixed width (adjust as needed) - const virtualHeight = 1400; // Fixed height (adjust as needed) - - // STEP 1. Get a DOM node to render - const html = note.data?.content?.html ?? ''; - let node; - if (html instanceof HTMLElement) { - node = html; - } else { - // If it's HTML string, render to a temporary hidden element - node = document.createElement('div'); - node.innerHTML = html; - document.body.appendChild(node); - } - - // Render to canvas with predefined width - const canvas = await html2canvas(node, { - useCORS: true, - scale: 2, // Keep at 1x to avoid unexpected enlargements - width: virtualWidth, // Set fixed virtual screen width - windowWidth: virtualWidth, // Ensure consistent rendering - windowHeight: virtualHeight - }); - - // Remove hidden node if needed - if (!(html instanceof HTMLElement)) { - document.body.removeChild(node); - } - - const imgData = canvas.toDataURL('image/jpeg', 0.7); - - // A4 page settings - const pdf = new jsPDF('p', 'mm', 'a4'); - const imgWidth = 210; // A4 width in mm - const pageHeight = 297; // A4 height in mm - - // Maintain aspect ratio - const imgHeight = (canvas.height * imgWidth) / canvas.width; - let heightLeft = imgHeight; - let position = 0; - - pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight); - heightLeft -= pageHeight; - - // Handle additional pages - while (heightLeft > 0) { - position -= pageHeight; - pdf.addPage(); - - pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight); - heightLeft -= pageHeight; + try { + await downloadPdf(selectedNote); + } catch (error) { + toast.error(`${error}`); } - - pdf.save(`${note.title}.pdf`); - } catch (error) { - console.error('Error generating PDF', error); - - toast.error(`${error}`); } }; @@ -303,12 +241,10 @@ }); onMount(async () => { - if ($page.url.searchParams.get('content')) { + if ($page.url.searchParams.get('content') !== null) { const content = $page.url.searchParams.get('content') ?? ''; - if (content) { - createNoteHandler(content); - return; - } + createNoteHandler(content); + return; } await init(); diff --git a/src/lib/components/notes/utils.ts b/src/lib/components/notes/utils.ts new file mode 100644 index 0000000000..46a7ea8458 --- /dev/null +++ b/src/lib/components/notes/utils.ts @@ -0,0 +1,103 @@ +export const downloadPdf = async (note) => { + const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([ + import('jspdf'), + import('html2canvas-pro') + ]); + + // Define a fixed virtual screen size + const virtualWidth = 1024; // Fixed width (adjust as needed) + const virtualHeight = 1400; // Fixed height (adjust as needed) + + // STEP 1. Get a DOM node to render + const html = note.data?.content?.html ?? ''; + const isDarkMode = document.documentElement.classList.contains('dark'); + + let node; + if (html instanceof HTMLElement) { + node = html; + } else { + const virtualWidth = 800; // px, fixed width for cloned element + + // Clone and style + node = document.createElement('div'); + + // title node + const titleNode = document.createElement('div'); + titleNode.textContent = note.title; + titleNode.style.fontSize = '24px'; + titleNode.style.fontWeight = 'medium'; + titleNode.style.paddingBottom = '20px'; + titleNode.style.color = isDarkMode ? 'white' : 'black'; + node.appendChild(titleNode); + + const contentNode = document.createElement('div'); + + contentNode.innerHTML = html; + + node.appendChild(contentNode); + + node.classList.add('text-black'); + node.classList.add('dark:text-white'); + node.style.width = `${virtualWidth}px`; + node.style.position = 'absolute'; + node.style.left = '-9999px'; + node.style.height = 'auto'; + node.style.padding = '40px 40px'; + + console.log(node); + document.body.appendChild(node); + } + + // Render to canvas with predefined width + const canvas = await html2canvas(node, { + useCORS: true, + backgroundColor: isDarkMode ? '#000' : '#fff', + scale: 2, // Keep at 1x to avoid unexpected enlargements + width: virtualWidth, // Set fixed virtual screen width + windowWidth: virtualWidth, // Ensure consistent rendering + windowHeight: virtualHeight + }); + + // Remove hidden node if needed + if (!(html instanceof HTMLElement)) { + document.body.removeChild(node); + } + + const imgData = canvas.toDataURL('image/jpeg', 0.7); + + // A4 page settings + const pdf = new jsPDF('p', 'mm', 'a4'); + const imgWidth = 210; // A4 width in mm + const pageWidthMM = 210; // A4 width in mm + const pageHeight = 297; // A4 height in mm + const pageHeightMM = 297; // A4 height in mm + + if (isDarkMode) { + pdf.setFillColor(0, 0, 0); + pdf.rect(0, 0, pageWidthMM, pageHeightMM, 'F'); // black bg + } + + // Maintain aspect ratio + const imgHeight = (canvas.height * imgWidth) / canvas.width; + let heightLeft = imgHeight; + let position = 0; + + pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight); + heightLeft -= pageHeight; + + // Handle additional pages + while (heightLeft > 0) { + position -= pageHeight; + pdf.addPage(); + + if (isDarkMode) { + pdf.setFillColor(0, 0, 0); + pdf.rect(0, 0, pageWidthMM, pageHeightMM, 'F'); // black bg + } + + pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight); + heightLeft -= pageHeight; + } + + pdf.save(`${note.title}.pdf`); +}; diff --git a/src/lib/components/playground/Chat.svelte b/src/lib/components/playground/Chat.svelte index 5b4d179429..c395c6fe6a 100644 --- a/src/lib/components/playground/Chat.svelte +++ b/src/lib/components/playground/Chat.svelte @@ -224,7 +224,7 @@ {$i18n.t('System Instructions')}
- {#if !showSystem} + {#if !showSystem && system.trim()}
{system}
diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte index 3c494e7609..0054eb2964 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte @@ -184,12 +184,6 @@ if (uploadedFile) { console.log(uploadedFile); - - if (uploadedFile.error) { - console.warn('File upload warning:', uploadedFile.error); - toast.warning(uploadedFile.error); - } - knowledge.files = knowledge.files.map((item) => { if (item.itemId === tempItemId) { item.id = uploadedFile.id; @@ -199,7 +193,14 @@ delete item.itemId; return item; }); - await addFileHandler(uploadedFile.id); + + if (uploadedFile.error) { + console.warn('File upload warning:', uploadedFile.error); + toast.warning(uploadedFile.error); + knowledge.files = knowledge.files.filter((file) => file.id !== uploadedFile.id); + } else { + await addFileHandler(uploadedFile.id); + } } else { toast.error($i18n.t('Failed to upload file.')); } diff --git a/src/lib/components/workspace/Models.svelte b/src/lib/components/workspace/Models.svelte index 5bc33fb4ce..2f9c5b5910 100644 --- a/src/lib/components/workspace/Models.svelte +++ b/src/lib/components/workspace/Models.svelte @@ -16,7 +16,7 @@ import { createNewModel, deleteModelById, - getModels as getWorkspaceModels, + getModelItems as getWorkspaceModels, toggleModelById, updateModelById } from '$lib/apis/models'; @@ -411,7 +411,9 @@ {#if (filteredModels ?? []).length !== 0}
{#each filteredModels as model (model.id)} -
- +
{/each}
{:else} diff --git a/src/lib/components/workspace/Models/ActionsSelector.svelte b/src/lib/components/workspace/Models/ActionsSelector.svelte index 4b3b52d4e8..efc332fc02 100644 --- a/src/lib/components/workspace/Models/ActionsSelector.svelte +++ b/src/lib/components/workspace/Models/ActionsSelector.svelte @@ -34,10 +34,17 @@
{ - _actions[action].selected = e.detail === 'checked'; - selectedActionIds = Object.keys(_actions).filter((t) => _actions[t].selected); + if (!_actions[action].is_global) { + _actions[action].selected = e.detail === 'checked'; + selectedActionIds = Object.keys(_actions).filter((t) => _actions[t].selected); + } }} />
diff --git a/src/lib/components/workspace/Models/Knowledge.svelte b/src/lib/components/workspace/Models/Knowledge.svelte index a5bc2a45dc..11f5dabd84 100644 --- a/src/lib/components/workspace/Models/Knowledge.svelte +++ b/src/lib/components/workspace/Models/Knowledge.svelte @@ -18,7 +18,7 @@ let loaded = false; let filesInputElement = null; - let inputFiles = []; + let inputFiles = null; const uploadFileHandler = async (file, fullContext: boolean = false) => { if ($user?.role !== 'admin' && !($user?.permissions?.chat?.file_upload ?? true)) { diff --git a/src/lib/components/workspace/Models/ModelMenu.svelte b/src/lib/components/workspace/Models/ModelMenu.svelte index a17b9258f3..a7a3469231 100644 --- a/src/lib/components/workspace/Models/ModelMenu.svelte +++ b/src/lib/components/workspace/Models/ModelMenu.svelte @@ -22,6 +22,7 @@ export let user; export let model; + export let editHandler: Function; export let shareHandler: Function; export let cloneHandler: Function; export let exportHandler: Function; @@ -61,6 +62,16 @@ align="start" transition={flyAndScale} > + { + editHandler(); + }} + > + +
{$i18n.t('Edit')}
+
+ { @@ -116,35 +127,25 @@ { - copyLinkHandler(); + cloneHandler(); }} > - + -
{$i18n.t('Copy Link')}
+
{$i18n.t('Clone')}
- {#if $config?.features.enable_community_sharing} - { - shareHandler(); - }} - > - -
{$i18n.t('Share')}
-
- {/if} +
{ - cloneHandler(); + copyLinkHandler(); }} > - + -
{$i18n.t('Clone')}
+
{$i18n.t('Copy Link')}
{$i18n.t('Export')}
+ {#if $config?.features.enable_community_sharing} + { + shareHandler(); + }} + > + +
{$i18n.t('Share')}
+
+ {/if} +
import Checkbox from '$lib/components/common/Checkbox.svelte'; + import Tooltip from '$lib/components/common/Tooltip.svelte'; import { getContext, onMount } from 'svelte'; export let tools = []; @@ -46,9 +47,11 @@ />
-
- {_tools[tool].name} -
+ +
+ {_tools[tool].name} +
+
{/each}
diff --git a/src/lib/components/workspace/Prompts.svelte b/src/lib/components/workspace/Prompts.svelte index 079ce85bdb..8cac0d5298 100644 --- a/src/lib/components/workspace/Prompts.svelte +++ b/src/lib/components/workspace/Prompts.svelte @@ -33,7 +33,7 @@ let promptsImportInputElement: HTMLInputElement; let loaded = false; - let importFiles = ''; + let importFiles = null; let query = ''; let prompts = []; diff --git a/src/lib/components/workspace/Prompts/PromptEditor.svelte b/src/lib/components/workspace/Prompts/PromptEditor.svelte index de71a1cb9b..21868be80e 100644 --- a/src/lib/components/workspace/Prompts/PromptEditor.svelte +++ b/src/lib/components/workspace/Prompts/PromptEditor.svelte @@ -106,7 +106,7 @@